> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.coinflow.cash/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.coinflow.cash/_mcp/server.

# Embedded Onboarding Integration Guide

This guide walks through a complete embedded onboarding integration, in the
order you'll build it. Complete [Partner Setup](/guides/embedded-onboarding/partner-setup)
first, and read the [security rules](/guides/embedded-onboarding/overview#security-rules)
and [rules for handling future changes](/guides/embedded-onboarding/overview#handling-future-changes)
before you start.

> **Info**
>
> The examples use sandbox URLs. In production, use the production equivalents:
>
> |                  | Sandbox                                  | Production                       |
> | :--------------- | :--------------------------------------- | :------------------------------- |
> | API              | `https://api-sandbox.coinflow.cash/api`  | `https://api.coinflow.cash/api`  |
> | Onboarding pages | `https://sandbox-merchant.coinflow.cash` | `https://merchant.coinflow.cash` |
>
> Values such as `acme-store`, `owner@acme.example.com` and
> `https://app.example.com` are **examples only**. Replace them with your own.
> The curl examples read your API key from the `COINFLOW_ADMIN_API_KEY` or
> `COINFLOW_API_KEY` environment variable.

#### Create and pre-fill a sub-merchant

Call [Create Submerchant](/api-reference/api-reference/sub-merchant/create-submerchant)
from your backend when a business signs up for payments on your platform.

**`Request`**

```bash Request
curl --request POST \
  --url https://api-sandbox.coinflow.cash/api/submerchant \
  --header "Authorization: $COINFLOW_ADMIN_API_KEY" \
  --header 'content-type: application/json' \
  --data '{
    "merchantId": "acme-store",
    "email": "owner@acme.example.com",
    "dba": "Acme Store",
    "industry": "retailEcommerce",
    "businessEmail": "support@acme.example.com",
    "billingEmailSameAsBusinessEmail": true,
    "websiteUrls": ["https://acme.example.com"],
    "privacyPolicyUrl": "https://acme.example.com/privacy",
    "termsOfServiceUrl": "https://acme.example.com/terms",
    "payinMethods": "card,applePay"
  }'
```

**`Response`**

```json Response
{
  "merchantId": "acme-store",
  "email": "owner@acme.example.com",
  "dba": "Acme Store",
  "industry": "retailEcommerce",
  "businessEmail": "support@acme.example.com",
  "billingEmail": "support@acme.example.com",
  "websiteUrl": "https://acme.example.com",
  "privacyPolicyUrl": "https://acme.example.com/privacy",
  "termsOfServiceUrl": "https://acme.example.com/terms",
  "payinMethods": "card,applePay",
  "products": "checkout"
}
```

* **`merchantId`** is your identifier for the sub-merchant: 3 to 50 letters, numbers and hyphens, unique across Coinflow. It's stored in lowercase, so on every later call for this sub-merchant, send the `merchantId` returned in the response as the `x-coinflow-submerchant-id` header. Later lookups match the exact case.
* **`email`** becomes the sub-merchant's account owner. Use a real inbox the business can open: Coinflow emails the account owner, for example a one-time code during settlement setup.
* **Every other field is optional.** Anything you send is shown to the sub-merchant pre-filled, and they can change it. Some fields don't apply to every sub-merchant; those are accepted and kept but not shown.
* Don't send the `x-coinflow-submerchant-id` header on this call. It fails with a `400`.

#### Pre-fill fields

| Field                                                      | Format                                                                                                         |
| :--------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------- |
| `dba`                                                      | Doing-business-as name, or the legal name if there is none                                                     |
| `industry`                                                 | One of the values listed on [Create Submerchant](/api-reference/api-reference/sub-merchant/create-submerchant) |
| `businessEmail`                                            | Email address                                                                                                  |
| `billingEmail`                                             | Email address. Leave out when `billingEmailSameAsBusinessEmail` is `true`                                      |
| `billingEmailSameAsBusinessEmail`                          | `true` to use `businessEmail` as the billing email. Requires `businessEmail`                                   |
| `businessPhoneNumber`                                      | At least 4 digits                                                                                              |
| `businessPhoneCountryCode`                                 | For example `+1`                                                                                               |
| `websiteUrls`                                              | 1 to 5 live website URLs                                                                                       |
| `developmentUrls`                                          | Up to 5 staging or development URLs                                                                            |
| `privacyPolicyUrl`, `termsOfServiceUrl`, `returnPolicyUrl` | URLs                                                                                                           |
| `payinMethods`                                             | Comma-separated pay-in method keys, for example `card,applePay,ach`                                            |
| `payoutMethods`                                            | Comma-separated payout method keys, for example `standard,card`                                                |
| `endUserJurisdictions`                                     | Comma-separated regions the business's customers are in, for example `US,EU,UK`                                |

Unknown fields are rejected with a `400`. The response returns the URLs as
separate fields (`websiteUrl`, `websiteUrl2`, ...) and adds `products`,
derived from the pay-in and payout methods you sent.

#### Updating pre-filled details

Call [Update Submerchant](/api-reference/api-reference/sub-merchant/update-submerchant)
with only the fields you want to change:

**`Request`**

```bash Request
curl --request PATCH \
  --url https://api-sandbox.coinflow.cash/api/submerchant/acme-store \
  --header "Authorization: $COINFLOW_ADMIN_API_KEY" \
  --header 'content-type: application/json' \
  --data '{"businessPhoneNumber": "4155550142", "businessPhoneCountryCode": "+1"}'
```

* Fields you leave out keep their current values. `websiteUrls` and `developmentUrls` replace the whole list.
* Once the sub-merchant submits its application, updates fail with a `409`.

> **Warning**
>
> Sub-merchant creation is limited to 30 per hour per API key. Past that,
> requests fail with a `429` and `"error": "CREATE_SUBMERCHANT_RATE_LIMITED"`.

#### Mint an embed session from your backend

When a signed-in user opens onboarding in your product, your backend mints an
embed session for their sub-merchant with
[Create Embed Session](/api-reference/api-reference/embed/create-embed-session)
and returns the token to your page.

**`Request`**

```bash Request
curl --request POST \
  --url https://api-sandbox.coinflow.cash/api/embed/session \
  --header "Authorization: $COINFLOW_ADMIN_API_KEY" \
  --header 'x-coinflow-submerchant-id: acme-store' \
  --header 'content-type: application/json' \
  --data '{"parentOrigin": "https://app.example.com"}'
```

**`Response`**

```json Response
{
  "token": "EMBED_SESSION_TOKEN",
  "expiresAt": 1767225600
}
```

* **`parentOrigin`** is the origin of the page that frames the iframe. It must be covered by an approved Whitelist URL. The iframe only posts events to this origin.
* **`expiresAt`** is in Unix seconds.

A minimal token endpoint in Node.js with Express:

**`Server`**

```typescript Server
app.post('/coinflow/onboarding-session', requireSignedInUser, async (req, res) => {
  // Look up the sub-merchant from your own session, never from the request.
  const submerchantId = await getSubmerchantIdForUser(req.user.id);

  const response = await fetch(
    'https://api-sandbox.coinflow.cash/api/embed/session',
    {
      method: 'POST',
      headers: {
        Authorization: process.env.COINFLOW_ADMIN_API_KEY,
        'x-coinflow-submerchant-id': submerchantId,
        'content-type': 'application/json',
      },
      body: JSON.stringify({parentOrigin: 'https://app.example.com'}),
    }
  );
  if (!response.ok) return res.sendStatus(502);

  const {token} = await response.json();
  res.set('Cache-Control', 'no-store').json({token});
});
```

#### Session lifetime and renewal

* Each token is valid for **15 minutes**. The iframe renews its own token about 2 minutes before it expires, so you mint once per visit.
* A session can't be renewed past **8 hours** after it was minted. After that the iframe posts an `onboarding.error` event with code `session_expired`; mint a new token and reload the iframe.
* A session stops working immediately if the sub-merchant is moved out of your account, either account is suspended, or the API key that minted it is disabled, deleted or downgraded below Admin.

If you call embed-session APIs directly, renew a token with
[Refresh Embed Session](/api-reference/api-reference/embed/refresh-embed-session).

#### Embed the onboarding iframe

Load the onboarding page with the token in the URL **fragment**:

```html
<iframe
  id="coinflow-onboarding"
  src="https://sandbox-merchant.coinflow.cash/embed/onboarding#token=EMBED_SESSION_TOKEN"
  title="Coinflow onboarding"
  allow="camera; microphone; clipboard-write"
  style="width: 100%; border: 0;"
></iframe>
```

* **The `allow` attribute is required.** Identity verification runs in a frame inside the iframe and uses the camera to capture ID documents and a selfie. A nested frame only gets the permissions every frame above it grants, so without `camera` the sub-merchant can't verify. Include `microphone` as well, since verification may request it. `clipboard-write` powers the **Copy link** buttons used to share verification links with business owners.
* **Use the fragment, not a query string.** The fragment is never sent to a server or leaked in `Referer` headers, and the iframe removes it from its address bar as soon as it loads.
* **Size it for one column.** Onboarding shows one step at a time and works down to about 320px wide. Set the iframe's height from `onboarding.heightChanged` events (next step) so it never shows its own scrollbar.
* **Don't sandbox it** unless the `sandbox` attribute includes `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`. Documents and terms open in a new tab, and without `allow-popups-to-escape-sandbox` that tab inherits the sandbox and can't display them.

The iframe only renders on approved sites. On any other site, or when the
token is missing or invalid, it shows a "Not allowed" message instead.

#### Follow progress

You have three ways to follow a sub-merchant's progress. Most integrations use
all three.

| Source                                                                   | Use it to                                                                                                     |
| :----------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------ |
| [Embed events](/guides/embedded-onboarding/embed-events)                 | Resize the iframe and react in your page while the sub-merchant is in it                                      |
| [Onboarding webhooks](/guides/embedded-onboarding/onboarding-webhooks)   | Update your records on your server when the application is submitted, approved or declined, or an RFI is sent |
| [Get Onboarding](/api-reference/api-reference/onboarding/get-onboarding) | Read the current status and steps at any time, for example to show a badge in your product                    |

Listen for embed events from your page:

**`Browser`**

```typescript Browser
const COINFLOW_ORIGIN = 'https://sandbox-merchant.coinflow.cash';
const iframe = document.getElementById('coinflow-onboarding') as HTMLIFrameElement;

window.addEventListener('message', event => {
  if (event.origin !== COINFLOW_ORIGIN) return;
  if (event.source !== iframe.contentWindow) return;
  const message = event.data;
  if (message?.source !== 'coinflow' || message.version !== 1) return;

  switch (message.type) {
    case 'onboarding.heightChanged':
      iframe.style.height = `${message.payload.height}px`;
      break;
    case 'onboarding.stepChanged':
      iframe.scrollIntoView({block: 'start'});
      break;
    case 'onboarding.submitted':
      showSubmittedBanner();
      break;
    case 'onboarding.error':
      if (message.payload.code === 'session_expired') reloadWithNewSession();
      break;
  }
});
```

Read the current status from your backend:

**`Request`**

```bash Request
curl --request GET \
  --url https://api-sandbox.coinflow.cash/api/onboarding \
  --header "Authorization: $COINFLOW_API_KEY" \
  --header 'x-coinflow-submerchant-id: acme-store'
```

**`Response`**

```json Response
{
  "merchantId": "acme-store",
  "status": "in_progress",
  "requirementsStatus": "determined",
  "requirementsRevision": 2,
  "openRfiCount": 0,
  "steps": [
    {"key": "profile", "status": "complete", "dependsOn": [], "blockedBy": [], "completionMethods": ["embed"]},
    {"key": "corporate_structure", "status": "complete", "dependsOn": ["profile"], "blockedBy": [], "completionMethods": ["embed"]},
    {"key": "verification", "status": "action_required", "dependsOn": ["profile", "corporate_structure"], "blockedBy": [], "completionMethods": ["embed"]},
    {"key": "application", "status": "blocked", "dependsOn": ["profile", "corporate_structure", "verification"], "blockedBy": ["verification"], "completionMethods": ["embed"]}
  ]
}
```

#### Onboarding statuses

| `status`          | Meaning                                                                                                |
| :---------------- | :----------------------------------------------------------------------------------------------------- |
| `not_started`     | The sub-merchant has no saved details yet                                                              |
| `in_progress`     | Details are saved but the application isn't submitted. A sub-merchant you pre-filled starts here       |
| `submitted`       | The application is in review and nothing is waiting on the sub-merchant                                |
| `action_required` | The application is submitted, but the sub-merchant has an open information request or a step to finish |
| `approved`        | The sub-merchant is approved and live                                                                  |
| `declined`        | The application was declined                                                                           |

#### Steps

`steps` lists only the steps that apply to this sub-merchant, in order. Each
step has a `status` of `blocked` (waiting on the steps in `blockedBy`),
`action_required`, `in_review` or `complete`.

| `key`                 | What the sub-merchant does                                               |
| :-------------------- | :----------------------------------------------------------------------- |
| `profile`             | Confirms its business details                                            |
| `corporate_structure` | Lists its owners and controllers                                         |
| `verification`        | Verifies the business and its owners' identities                         |
| `questions`           | Answers underwriting questions                                           |
| `terms`               | Accepts your platform's terms, when you have any                         |
| `application`         | Reviews and submits the application                                      |
| `wallet`              | Chooses where its funds settle (see [Settlement step](#settlement-step)) |

While `requirementsStatus` is `pending`, more steps may be added once the
sub-merchant completes its profile. `requirementsRevision` increases each time
the list of steps is recalculated.

#### Handle information requests

After the sub-merchant applies, or at any time after it's live, Coinflow's
underwriting team may send it a request for information (RFI). Coinflow doesn't
email the sub-merchant about these, so your platform must show them.

1. Listen for the `Sub-merchant RFI Sent` webhook, or call [List RFIs](/api-reference/api-reference/rfi/list-rfis) with `actionRequired=true`.
2. Mint an embed session for the sub-merchant, exactly as in step 2.
3. Open the RFI in an iframe at `/embed/rfi/{rfiId}#token=EMBED_SESSION_TOKEN`.

See [Information Requests](/guides/embedded-onboarding/information-requests)
for the details.

## Settlement step

When settlement options are enabled for your sub-merchants (see
[Partner Setup](/guides/embedded-onboarding/partner-setup#4-settlement-options-optional)),
onboarding includes a `wallet` step where the sub-merchant chooses where its
funds settle. It only offers the options enabled for that sub-merchant.

* The step never blocks submitting the application. After submitting, the iframe opens the first step still needing action, usually this one, and the overall `status` stays `action_required` until it's complete.
* For a Coinflow-managed settlement account, Coinflow emails a one-time code to the account owner (the `email` you created the sub-merchant with) and the sub-merchant approves the account in the iframe. If they leave part way through, the step offers to finish setup next time.
* An external settlement destination can't be changed after it's set.

## Identity verification

* Verification runs inside the iframe. If the sub-merchant's device has no camera or it's blocked, they may be offered the option to continue verification on their phone and come back.
* Verification is decided after the sub-merchant finishes. The step shows that verification was submitted, and they can carry on with the other steps. The application can be submitted once verification is approved.
* Verification links for other business owners can be copied from the iframe and shared with them.

## Opening onboarding as a link

If you'd rather not host an iframe, you can send the sub-merchant to the same
pages as a full page hosted by Coinflow:

```
https://sandbox-merchant.coinflow.cash/embed/onboarding#token=EMBED_SESSION_TOKEN
https://sandbox-merchant.coinflow.cash/embed/rfi/{rfiId}#token=EMBED_SESSION_TOKEN
```

* **Mint the session when the user clicks through**, and redirect straight to the link. It's the same 15-minute session as the iframe, so don't email or store the link. Reloading the page needs a fresh link.
* **Omit `parentOrigin`** to mint a link-only session. A link-only session refuses to load inside any iframe. A session minted with `parentOrigin` works both ways.
* **Pass `returnUrl`** to show a **Return to your platform** button after the sub-merchant submits. Its origin must be covered by an approved Whitelist URL.

  ```json
  {"returnUrl": "https://app.example.com/onboarding/done"}
  ```

  With neither `parentOrigin` nor `returnUrl`, minting doesn't need any Whitelist URL.
* **No embed events are posted** when there's no parent page. Follow progress with webhooks and [Get Onboarding](/api-reference/api-reference/onboarding/get-onboarding).

## Testing in sandbox

1. Complete [Partner Setup](/guides/embedded-onboarding/partner-setup) in the sandbox dashboard, approving your local or staging origin.
2. Create a sub-merchant in sandbox, mint a session and load the iframe from the approved origin.
3. Point a sandbox webhook URL at your server to receive onboarding events.

## Troubleshooting

#### Minting returns 400 about Whitelist URLs

`parentOrigin` (or `returnUrl`) isn't covered by an approved Whitelist URL, or your account has none. Check the exact origin, including scheme and port, and that the request was approved. See [Partner Setup](/guides/embedded-onboarding/partner-setup#2-approve-the-sites-that-embed-onboarding).

#### Minting returns 401

The request wasn't made with an Admin API key, or `x-coinflow-submerchant-id` names a merchant that isn't one of your sub-merchants.

#### The iframe shows "Not allowed on this site"

A page framing the iframe isn't on an approved origin. Every page in the frame chain must be covered by a Whitelist URL. A link-only session (minted without `parentOrigin`) shows "This link can't be opened inside another site" when framed instead.

#### The iframe loads but no embed events arrive

Events are only posted to the `parentOrigin` the session was minted with. Check that it exactly matches your page's origin, including scheme and port, and that your listener compares `event.origin` with the Coinflow onboarding origin for the environment.

#### The camera doesn't open during verification

The iframe is missing `allow="camera; microphone; clipboard-write"`, or a frame above it doesn't grant those permissions.

#### Updating a sub-merchant returns 409

The sub-merchant has already submitted its application. Pre-filled details can't be changed after that.