# Account setup & onboarding

Before you can accept live payments you must complete three sequential phases: **sign-up** (a short REST wizard that ends with a Demo account), optionally **additional company entities** (a GraphQL chain), and **KYB** (identity verification via our KYB provider). At the end of KYB approval your account's `operationalMode` flips from `Demo` to `Live`.

> [!NOTE]
> This guide mixes REST calls and GraphQL mutations. REST calls hit `https://api.fynex.ai` and use no auth header — they rely on an HttpOnly session cookie. GraphQL calls hit `https://api.fynex.ai/dashboard/graphql` and also use the cookie. See [GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth) for a standalone reference.

---

## Overview

```
Phase 1 — REST                     Phase 2 — GraphQL (optional)        Phase 3 — REST + KYB
──────────────                     ────────────────────────────        ────────────────────
POST /onboarding/start             createOrganization (staff only)     POST /api/v1/kyb/initiate
    ↓ (cookie set)                      ↓                                  ↓ (KYB token)
PATCH /onboarding/step ×N          createLegalEntityByUuid             KYB Web SDK (customer fills form)
    ↓                                   ↓ (legalEntityId)                  ↓
POST /verify-email                 createOrganizationProfileByUuid     Fynex staff review + approve
    ↓                                   ↓                                  ↓
POST /onboarding/complete          additional entities registered      operationalMode → Live
    ↓ (org + legal entity + profile + Demo seller, sandbox provisioned)
```

---

## Phase 1 — Sign-up (draft → verified → complete)

Sign-up is a short, resumable wizard over REST. Four calls, all on
`https://api.fynex.ai`, all cookie-authenticated after the first:

| # | Call | What it does |
|---|------|--------------|
| 1 | `POST /api/v1/onboarding/start` | Creates a **draft** user from `email` + `password`, sets the HttpOnly `dashboard_session` cookie, and e-mails a verification code. No auth header. |
| 2 | `PATCH /api/v1/onboarding/step` | Saves one wizard step: `{ "step": "<name>", "data": { … } }`. Steps must be submitted in order; a step may be re-submitted to edit it. |
| 3 | `POST /api/v1/verify-email` | Consumes the e-mailed code: `{ "code": "123456" }`. `POST /api/v1/verify-email/resend` sends a fresh one. |
| 4 | `POST /api/v1/onboarding/complete` | Turns the draft into a real account: organization, legal entity, organization profile and a **Demo** seller account, pre-provisioned as a sandbox. Refused with `403 emailNotVerified` until step 3 is done. |

> [!NOTE]
> `start` already sets the session cookie — there is no separate sign-in step
> during sign-up. `POST /api/v1/login/dashboard` is for **returning** sessions.

### Step names and what each collects

The step sequence depends on `legalEntityType`, chosen in the first step:

```
legal_entity_type → personal_details → business_details → business_solutions → extra_info   (Company)
legal_entity_type → personal_details → individual_details → extra_info                       (Individual)
```

Every `data` object is a partial of one form; send only the fields the step
collects. The fields are camelCase and match the wizard: `legalEntityType`
(`Company` or `Individual`), `firstName`, `lastName`, `phone`, `role`,
`businessName`, `industry`, `businessModel`, `country` (ISO 3166-1 alpha-2),
`businessWebsite`, `registrationNumber`, `registeredAddress`,
`estimatedMonthlyVolume`, `averageTransactionSize`, `paymentFlowsDescription`.
`country` is screened against the supported jurisdictions when it is submitted;
an unsupported country answers `422 countryNotSupported`.

#### curl

```bash
# 1 — start the draft; -c saves the session cookie, -b sends it on later calls
curl -sS -c cookies.txt -X POST https://api.fynex.ai/api/v1/onboarding/start \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@example.com", "password": "s3cur3-P@ssw0rd"}'

# 2 — the wizard steps, in order
curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \
  -H "Content-Type: application/json" \
  -d '{"step": "legal_entity_type", "data": {"legalEntityType": "Company"}}'

curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \
  -H "Content-Type: application/json" \
  -d '{"step": "personal_details", "data": {"firstName": "Alice", "lastName": "Smith", "phone": "+447700900000", "role": "owner"}}'

curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \
  -H "Content-Type: application/json" \
  -d '{"step": "business_details", "data": {"businessName": "Acme Ltd", "industry": "SaaS", "country": "GB", "registrationNumber": "12345678"}}'

curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \
  -H "Content-Type: application/json" \
  -d '{"step": "business_solutions", "data": {"businessModel": "saas"}}'

curl -sS -b cookies.txt -X PATCH https://api.fynex.ai/api/v1/onboarding/step \
  -H "Content-Type: application/json" \
  -d '{"step": "extra_info", "data": {"businessWebsite": "https://acme.example"}}'

# 3 — the code from the verification e-mail
curl -sS -b cookies.txt -X POST https://api.fynex.ai/api/v1/verify-email \
  -H "Content-Type: application/json" \
  -d '{"code": "123456"}'

# 4 — complete: organization, legal entity, profile and a Demo seller account
curl -sS -b cookies.txt -X POST https://api.fynex.ai/api/v1/onboarding/complete
# Response: { "userId": "42", "organizationUuid": "…", "legalEntityIds": [77],
#             "organizationProfileIds": [12], "permissionTypes": [...] }
```

#### JavaScript

```js
const BASE = 'https://api.fynex.ai';
const post = (path, body, method = 'POST') => fetch(`${BASE}${path}`, {
  method,
  credentials: 'include',            // stores and sends the HttpOnly cookie
  headers: { 'Content-Type': 'application/json' },
  body: body === undefined ? undefined : JSON.stringify(body),
});

await post('/api/v1/onboarding/start', { email: 'alice@example.com', password: 's3cur3-P@ssw0rd' });
for (const [step, data] of [
  ['legal_entity_type', { legalEntityType: 'Company' }],
  ['personal_details', { firstName: 'Alice', lastName: 'Smith', phone: '+447700900000', role: 'owner' }],
  ['business_details', { businessName: 'Acme Ltd', industry: 'SaaS', country: 'GB', registrationNumber: '12345678' }],
  ['business_solutions', { businessModel: 'saas' }],
  ['extra_info', { businessWebsite: 'https://acme.example' }],
]) {
  await post('/api/v1/onboarding/step', { step, data }, 'PATCH');
}
await post('/api/v1/verify-email', { code: codeFromEmail });
const { organizationUuid, legalEntityIds } = await (await post('/api/v1/onboarding/complete')).json();
```

What `complete` leaves you with is a working **Demo** account: the sandbox
auto-provisioning that runs on completion adds wallets, a demo card terminal, a
demo counterparty payee, a pre-set split rule and three test customers, and
issues the account's first API key pair (retrieve the secret from
**Integration → API keys** in the dashboard — see [Getting your API token](#getting-your-api-token)).
Nothing here moves real money; Phase 3 is what takes the account live.

---

## Phase 2 — Company creation (GraphQL chain)

> [!IMPORTANT]
> **Most integrations don't need this phase.** `POST /api/v1/onboarding/complete` (Phase 1)
> **already creates** the organization, legal entity, and organization profile for you in a
> single transaction — its response returns `organizationUuid`, `legalEntityIds` and
> `organizationProfileIds`. The mutations below are only for advanced cases where you manage
> **additional** legal entities or profiles under one account. To get an **API token**, you
> don't run these — see [Getting your API token](#getting-your-api-token) below.

Company creation runs as sequential GraphQL mutations on `/dashboard/graphql`. Each step depends on the identifier returned by the previous one. The organization is addressed by its **UUID** (`organizationUuid` from `complete`); the legal entity by its numeric `legalEntityId`.

```
createOrganization  ──► organizationUuid    (staff surface only — see Step 1)
                              │
                              ▼
createLegalEntity   ──► legalEntityId (takes organizationUuid)
                              │
                              ▼
createOrganizationProfile  (takes both organizationUuid + legalEntityId)
```

> [!CAUTION]
> **Partial rollback:** if `createOrganizationProfile` fails after the first two mutations have succeeded, you are left with an orphaned `Organization` and `LegalEntity`. There is no automatic rollback. Contact your Fynex representative to clean up and retry.

### Helper: send a GraphQL mutation

All three calls below share the same shape. Use this wrapper or your preferred GraphQL client.

#### curl (reusable)

```bash
gql() {
  curl -sS -b cookies.txt \
    -X POST https://api.fynex.ai/dashboard/graphql \
    -H "Content-Type: application/json" \
    -d "$1"
}
```

#### JavaScript (reusable)

```js
async function gql(query, variables = {}) {
  const res = await fetch('https://api.fynex.ai/dashboard/graphql', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const body = await res.json();
  if (body.errors?.length) throw new Error(body.errors[0].message);
  return body.data;
}
```

#### Python (reusable)

```python
def gql(session, query, variables=None):
    resp = session.post(
        'https://api.fynex.ai/dashboard/graphql',
        json={'query': query, 'variables': variables or {}},
    )
    resp.raise_for_status()
    body = resp.json()
    if 'errors' in body:
        raise RuntimeError(body['errors'][0]['message'])
    return body['data']
```

---

### Step 1 — Create an organization

> [!CAUTION]
> **`createOrganization` is not exposed on `/dashboard/graphql`.** The mutation exists on the
> Fynex staff surface only; a dashboard session calling it receives
> `Cannot query field "createOrganization" on type "Mutation"`. Your organization is created by
> `POST /api/v1/onboarding/complete` (Phase 1), which returns its `organizationUuid`. To add an
> **additional** organization under the same account, ask your Fynex representative for it and
> continue from Step 2 with the `organizationUuid` they give you.

For reference, the operation as it exists on the staff surface — it takes a single
`displayName` and returns the `organizationUuid` the next two mutations need:

```graphql
# Not available on /dashboard/graphql — staff surface only. Shown for reference.
mutation CreateOrganization($input: CreateOrganizationInput!) {
  createOrganization(input: $input) {
    organizationUuid
    displayName
  }
}
```

**Input: `CreateOrganizationInput`**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `displayName` | String | Yes | Your company trading name as it appears in Fynex. |

The samples below address the organization two ways. **Prefer the `…ByUuid` mutations**,
which take the `organizationUuid` that `complete` returns:

```graphql
mutation CreateLegalEntityByUuid($input: CreateLegalEntityByUuidInput!) {
  createLegalEntityByUuid(input: $input) {
    id
    name
    status
  }
}
```

```graphql
mutation CreateOrganizationProfileByUuid($input: CreateOrganizationProfileByUuidInput!) {
  createOrganizationProfileByUuid(input: $input) {
    id
    statementDescriptor
  }
}
```

The legacy `createLegalEntity` and `createOrganizationProfile` mutations were removed.
Use the UUID-only `…ByUuid` mutations above and pass the `organizationUuid` returned by
onboarding (or supplied by your Fynex representative).

---

### Step 2 — Create a legal entity

`createLegalEntity` registers the incorporated company behind the organization. Requires the `organizationUuid` from step 1.

```graphql
mutation CreateLegalEntity($input: CreateLegalEntityByUuidInput!) {
  createLegalEntityByUuid(input: $input) {
    id
    name
    legalName
    status
  }
}
```

**Input: `CreateLegalEntityByUuidInput`**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `organizationUuid` | ID | Yes | UUID returned by `createOrganization`. |
| `name` | String | Yes | Short display name for the legal entity. |
| `type` | LegalEntityType | Yes | Corporate form, e.g. `limited_company`, `sole_trader`. |
| `legalName` | String | Yes | Full registered legal name. |
| `registrationCountry` | CountryCode | Yes | ISO 3166-1 alpha-2, e.g. `GB`. |
| `registrationNumber` | String | Yes | Companies House / state registration number. |
| `incorporationDate` | Time | No | RFC 3339 date, e.g. `"2019-03-15T00:00:00Z"`. |
| `globalTaxId` | String | No | VAT / EIN / tax identification number. |

#### curl

```bash
gql "{
  \"query\": \"mutation (\$input: CreateLegalEntityByUuidInput!) { createLegalEntityByUuid(input: \$input) { id name status } }\",
  \"variables\": {
    \"input\": {
      \"organizationUuid\": \"$ORG_UUID\",
      \"name\": \"Acme Ltd\",
      \"type\": \"limited_company\",
      \"legalName\": \"Acme Limited\",
      \"registrationCountry\": \"GB\",
      \"registrationNumber\": \"12345678\",
      \"incorporationDate\": \"2019-03-15T00:00:00Z\"
    }
  }
}"
# Response: { "data": { "createLegalEntity": { "id": 3, "name": "Acme Ltd", "status": "pending" } } }
LEGAL_ENTITY_ID=3
```

#### JavaScript

```js
const { createLegalEntityByUuid } = await gql(
  `mutation ($input: CreateLegalEntityByUuidInput!) {
     createLegalEntityByUuid(input: $input) { id name status }
   }`,
  {
    input: {
      organizationUuid,
      name: 'Acme Ltd',
      type: 'limited_company',
      legalName: 'Acme Limited',
      registrationCountry: 'GB',
      registrationNumber: '12345678',
      incorporationDate: '2019-03-15T00:00:00Z',
    },
  }
);
const legalEntityId = createLegalEntityByUuid.id; // e.g. 3
```

#### Python

```python
data = gql(session, '''
  mutation($input: CreateLegalEntityByUuidInput!) {
    createLegalEntityByUuid(input: $input) { id name status }
  }
''', {
    'input': {
        'organizationUuid': organization_uuid,
        'name': 'Acme Ltd',
        'type': 'limited_company',
        'legalName': 'Acme Limited',
        'registrationCountry': 'GB',
        'registrationNumber': '12345678',
        'incorporationDate': '2019-03-15T00:00:00Z',
    }
})
legal_entity_id = data['createLegalEntityByUuid']['id']  # e.g. 3
```

---

### Step 3 — Create an organization profile

`createOrganizationProfile` links the legal entity to the organization and sets operational parameters. This completes the company-creation chain.

```graphql
mutation CreateOrganizationProfile($input: CreateOrganizationProfileByUuidInput!) {
  createOrganizationProfileByUuid(input: $input) {
    id
    countryCode
    statementDescriptor
    timezone
  }
}
```

**Input: `CreateOrganizationProfileByUuidInput`**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `organizationUuid` | ID | Yes | From step 1. |
| `legalEntityId` | Int | Yes | From step 2. |
| `countryCode` | CountryCode | Yes | Country of primary operation, e.g. `GB`. |
| `statementDescriptor` | String | Yes | Text that appears on the customer's bank statement (max ~22 chars). |
| `timezone` | String | Yes | IANA timezone, e.g. `Europe/London`. The dashboard defaults to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. |
| `industry` | String | No | Business industry / MCC category. |
| `platformRoleId` | Int | No | Assigned by Fynex — leave unset unless instructed. |

#### curl

```bash
gql "{
  \"query\": \"mutation (\$input: CreateOrganizationProfileByUuidInput!) { createOrganizationProfileByUuid(input: \$input) { id statementDescriptor } }\",
  \"variables\": {
    \"input\": {
      \"organizationUuid\": \"$ORG_UUID\",
      \"legalEntityId\": $LEGAL_ENTITY_ID,
      \"countryCode\": \"GB\",
      \"statementDescriptor\": \"ACME LTD\",
      \"timezone\": \"Europe/London\"
    }
  }
}"
```

#### JavaScript

```js
const { createOrganizationProfileByUuid } = await gql(
  `mutation ($input: CreateOrganizationProfileByUuidInput!) {
     createOrganizationProfileByUuid(input: $input) { id statementDescriptor }
   }`,
  {
    input: {
      organizationUuid,
      legalEntityId,
      countryCode: 'GB',
      statementDescriptor: 'ACME LTD',
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    },
  }
);
```

#### Python

```python
import datetime

data = gql(session, '''
  mutation($input: CreateOrganizationProfileByUuidInput!) {
    createOrganizationProfileByUuid(input: $input) { id statementDescriptor }
  }
''', {
    'input': {
        'organizationUuid': organization_uuid,
        'legalEntityId': legal_entity_id,
        'countryCode': 'GB',
        'statementDescriptor': 'ACME LTD',
        'timezone': 'Europe/London',
    }
})
profile_id = data['createOrganizationProfile']['id']
```

---

## Getting your API token

Your API bearer token belongs to a **seller account**. Create one with the `createSellerAccount`
mutation — it mints and returns the token immediately. You need a `legalEntityId` and an
`organizationProfileId` (both came back in your login response), plus a `name` and `currency`.

```bash
curl -sS -b cookies.txt -X POST https://api.fynex.ai/dashboard/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation Create($input: CreateSellerAccountInput!) { createSellerAccount(input: $input) { id name status operationalMode authorizationToken } }",
    "variables": { "input": { "legalEntityId": 3, "organizationProfileId": 5, "name": "Acme Ltd", "currency": "GBP" } }
  }'
# data.createSellerAccount.authorizationToken is your /payments-api/v1 Bearer token
```

The dashboard equivalent is the **Integration** page (reveal / copy, or **Generate Token**). To
rotate, call `regenerateSellerAccountToken(id:)` or use the dashboard's **Regenerate** button.

> [!IMPORTANT]
> A new seller account is created in `Demo` mode and **activated automatically**, so the token
> works immediately for **sandbox** payments. **KYB approval** is what flips the account to `Live`
> for real-money processing (with a Fynex-assigned live terminal). If a sandbox call returns
> `403 seller account is not active`, the token is still valid — the account just isn't active
> yet. See [Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/authentication).

---

## Phase 3 — KYB (Know Your Business)

Fynex uses a third-party KYB provider. Your integration obtains a short-lived KYB access token from Fynex, embeds the provider's Web SDK in your UI, and lets the customer fill the verification form. Fynex staff then review the submission and approve the account.

All three KYB endpoints require the `dashboard_session` cookie.

| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/kyb/initiate` | Start a KYB application. Returns `{ token, status }`. |
| `GET /api/v1/kyb/token` | Refresh an existing KYB access token (tokens expire). |
| `GET /api/v1/kyb/status` | Poll the current KYB state. |

1. **Initiate KYB and obtain the KYB access token**

#### curl

```bash
curl -sS -b cookies.txt \
  -X POST https://api.fynex.ai/api/v1/kyb/initiate \
  -H "Content-Type: application/json"
# Response: { "token": "_act-...", "status": "pending" }
```

#### JavaScript

```js
const res = await fetch('https://api.fynex.ai/api/v1/kyb/initiate', {
  method: 'POST',
  credentials: 'include',
});
const { token, status } = await res.json();
```

#### Python

```python
resp = session.post('https://api.fynex.ai/api/v1/kyb/initiate')
resp.raise_for_status()
kyb = resp.json()
kyb_token = kyb['token']
```

2. **Embed the KYB provider's Web SDK in your UI**

   Pass the token to the [provider's Web SDK](https://developers.sumsub.com/web-sdk/) to render the verification form. When the token expires (these tokens are short-lived), refresh it:

   ```js
   // When the SDK calls your token refresh handler:
   const refreshRes = await fetch('https://api.fynex.ai/api/v1/kyb/token', {
     credentials: 'include',
   });
   const { token: newToken } = await refreshRes.json();
   return newToken; // return to the SDK
   ```

   A minimal SDK integration:

   ```html
   <div id="sumsub-websdk-container"></div>
   <script src="https://static.sumsub.com/idensic/static/sns-websdk-builder.js"></script>
   <script>
     const snsWebSdkInstance = snsWebSdk
       .init(KYB_TOKEN, () =>
         fetch('/api/kyb/token', { credentials: 'include' })
           .then(r => r.json())
           .then(d => d.token)
       )
       .withConf({ lang: 'en' })
       .withOptions({ addViewportTag: false, adaptIframeHeight: true })
       .on('idCheck.onApplicantSubmitted', () => console.log('Submitted'))
       .build();

     snsWebSdkInstance.launch('#sumsub-websdk-container');
   </script>
   ```

3. **Poll KYB status**

   After the customer submits the form, Fynex staff review the application. Poll `/api/v1/kyb/status` until the status is terminal.

   ```js
   const statusRes = await fetch('https://api.fynex.ai/api/v1/kyb/status', {
     credentials: 'include',
   });
   const { status } = await statusRes.json();
   // status values: "init" | "pending" | "approved" | "rejected"
   ```

   When approved, your `SellerAccount.operationalMode` flips from `Demo` to `Live`.

> [!CAUTION]
> **KYB token expiry.** KYB access tokens are short-lived. If you get a `401` or `invalid token` error from the SDK, call `GET /api/v1/kyb/token` to refresh. Pass the refresh function directly to the SDK's token-provider callback so it can renew tokens automatically without user interaction.

---

## Common pitfalls

| Pitfall | Fix |
|---------|-----|
| `complete` answers `403 emailNotVerified` | Consume the e-mailed code with `POST /api/v1/verify-email` first; `POST /api/v1/verify-email/resend` sends a fresh one. |
| `step` answers `409 invalidDraftState` | Steps must be submitted in order; you skipped one, or the draft is already completed. |
| `createOrganizationProfile` fails mid-chain | No automatic rollback. Contact your Fynex representative to clean up orphaned records before retrying. |
| KYB token expired | Call `GET /api/v1/kyb/token` and pass the refreshed token to the SDK. |
| Payments still in `Demo` mode after approval | Check `kybStatus` via GraphQL; if `approved`, allow a few minutes for the `operationalMode` flip. If it persists, contact support. |
| `timezone` missing from `createOrganizationProfile` | Required field. Use `Intl.DateTimeFormat().resolvedOptions().timeZone` in a browser or an IANA string server-side. |

## See also

- **[GraphQL Authentication](https://api.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Full reference for session cookies and the /dashboard/graphql endpoint.
- **[Going Live](https://api.fynex.ai/payments-api/v2/docs#tag/going-live)** — Checklist and final steps once KYB is approved and operationalMode is live.
- **[Hosted Checkout](https://api.fynex.ai/payments-api/v2/docs#tag/hosted-checkout)** — Start accepting payments immediately after your account is active.
