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

# Quickstart — zero to first send

> Take a sandbox user from nothing to a completed transfer in eleven calls, using identity values that resolve.

<div className="bmoni-spine">
  <span>Lifecycle</span>
  <a data-stage="1" href="/lifecycle#1-create-the-user">User</a>
  <a data-stage="2" href="/lifecycle#2-provision-the-smart-wallet">Wallet</a>
  <a data-stage="3" href="/lifecycle#3-verify-identity-kyc">KYC</a>
  <a data-stage="4" href="/lifecycle#4-activate-the-rail">Rail</a>
  <a data-stage="5" href="/lifecycle#5-fund-the-wallet">Fund</a>
  <a data-stage="6" href="/lifecycle#6-move-money">Move money</a>
</div>

This page walks a brand-new sandbox user all the way to a settled transfer, using real values so every call runs as written.

Eleven calls, in one order. The order matters more than it looks: wallet creation comes **before** onboarding, because onboarding needs the wallet address.

<Note>
  This is the server-side API walkthrough. If you are building a Flutter app with `bmoni_embedded_sdk`, start with the [SDK quickstart](/quickstart) instead — it covers the client side of the same lifecycle.
</Note>

## Before you start

* A sandbox API key, sent as `x-api-key` on every request.
* A provisioned wallet from `bmoni_embedded_sdk`. Its address becomes `userOwnerAddress`, and the SDK signs twice below.
* The **Bunch Dillon** persona from [Sandbox test data](/api-reference/sandbox-test-data). Use its details verbatim — verification matches them.

```bash theme={null}
export BASE_URL="https://embedded-dev.bmoni.com"
export API_KEY="pk_a025cacbf33a_76fb864113f3540909de5b1da39cc146906e35b1c6d4d1e4"
```

That shared sandbox key works against the development base URL only. Get your own key before you touch production, whose base URL is `https://embedded.bmoni.com` — see [Base URL](/api-reference/introduction#base-url).

<Warning>
  You sign twice in this flow, with **two different methods**. Step 3 signs a text message with the EIP-191 prefix. Step 11 signs a raw 32-byte digest without it. Using the wrong one for either step fails, and the errors do not say which mistake you made. Each step below names the method explicitly.
</Warning>

## 1. Create the user

Use the persona's name and phone, converted to E.164.

```http theme={null}
POST /v1/users
{
  "firstName": "Bunch",
  "lastName": "Dillon",
  "email": "bunch.dillon@example.com",
  "phoneNumber": "+2348000000000"
}
```

Keep `bmoniUserId` from the response. It is the `{userId}` path parameter for every later call — not your own employee identifier.

<Tip>
  A `409` here means a user already holds that email or phone. The message names which. That is the correct response to a retry of a create that already succeeded — recover the existing user rather than retrying. See [Retries and duplicates](/api-reference/errors#retries-and-duplicates).
</Tip>

## 2. Submit the KYC profile

```http theme={null}
PATCH /v1/users/{userId}/kyc
{
  "personalInfo": {
    "firstName": "Bunch",
    "lastName": "Dillon",
    "dateOfBirth": "1990-01-15",
    "gender": "male"
  },
  "addressDetails": {
    "street": "15 Admiralty Way",
    "city": "Lagos",
    "state": "Lagos",
    "countryCode": "NGA"
  }
}
```

<Warning>
  Do not send `occupation` as free text. An occupation that does not resolve to a code is **silently dropped** — the call returns success and your employment data is gone. Fetch a code from `GET /v1/users/{userId}/kyc/occupations?search=…` and send `occupationCode` instead. See [Employment data is dropped](/api-reference/errors#employment-data-is-dropped-when-the-occupation-does-not-resolve).
</Warning>

## 3. Prove you control the owner address

Wallet creation requires the owner address to prove control first.

```http theme={null}
POST /v1/users/{userId}/smart-wallets/owner-proof-challenges
{
  "currency": "CNGN",
  "userOwnerAddress": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
}
```

The response carries a `challengeId` and a `message`. Sign the `message` as text, with the EIP-191 prefix — the standard `personal_sign` your language's Ethereum library already provides.

<CodeGroup>
  ```ts TypeScript theme={null}
  import { ethers } from 'ethers'

  const wallet = new ethers.Wallet(OWNER_PRIVATE_KEY)

  // signMessage applies the EIP-191 prefix, which is what this step wants.
  const ownerProofSignature = await wallet.signMessage(challenge.message)
  ```

  ```python Python theme={null}
  from eth_account import Account
  from eth_account.messages import encode_defunct

  signed = Account.sign_message(
      encode_defunct(text=challenge["message"]),
      OWNER_PRIVATE_KEY,
  )
  owner_proof_signature = "0x" + signed.signature.hex()
  ```

  ```go Go theme={null}
  import (
      "encoding/hex"

      "github.com/ethereum/go-ethereum/accounts"
      "github.com/ethereum/go-ethereum/crypto"
  )

  key, _ := crypto.HexToECDSA(ownerPrivateKey) // no 0x prefix

  // TextHash applies the EIP-191 prefix.
  sig, _ := crypto.Sign(accounts.TextHash([]byte(challenge.Message)), key)
  sig[64] += 27 // go-ethereum returns v as 0/1; Ethereum expects 27/28

  ownerProofSignature := "0x" + hex.EncodeToString(sig)
  ```

  ```php PHP theme={null}
  use Elliptic\EC;
  use kornrunner\Keccak;

  // EIP-191: prefix the message, then hash, then sign.
  $prefixed = "\x19Ethereum Signed Message:\n" . strlen($challenge['message']) . $challenge['message'];
  $digest   = Keccak::hash($prefixed, 256);

  $sig = (new EC('secp256k1'))
      ->keyFromPrivate($ownerPrivateKey)
      ->sign($digest, ['canonical' => true]);

  $ownerProofSignature = '0x'
      . str_pad(gmp_strval($sig->r->toString(), 16), 64, '0', STR_PAD_LEFT)
      . str_pad(gmp_strval($sig->s->toString(), 16), 64, '0', STR_PAD_LEFT)
      . dechex($sig->recoveryParam + 27);
  ```
</CodeGroup>

Step 11 signs differently — see [the comparison](#the-two-signatures-side-by-side).

The challenge expires after **10 minutes** and is consumed on successful wallet creation. Request a fresh one if you are slow or if creation fails.

## 4. Create the smart wallet

```http theme={null}
POST /v1/users/{userId}/smart-wallets/create-managed
{
  "currency": "CNGN",
  "userOwnerAddress": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
  "ownerProofChallengeId": "…",
  "ownerProofSignature": "0x…"
}
```

One call handles prepare, sign, deploy, and owner registration. Keep the returned `smartWalletId` and the wallet address.

## 5. Start onboarding

Now that the wallet exists, onboarding can reference it. **This is why wallet creation comes first** — `ngnWalletAddress` is required here.

```http theme={null}
POST /v1/users/{userId}/onboarding/start-nigeria
{
  "bvn": "95888168924",
  "ngnWalletAddress": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
  "ngnWalletIndex": 0
}
```

That BVN is Bunch Dillon's. It resolves because the profile in step 2 carries the matching name.

## 6, 7, 8. Upload the documents

Three separate uploads. All three are required before verification can complete.

```http theme={null}
POST /v1/users/{userId}/kyc/documents/identification
POST /v1/users/{userId}/kyc/documents/proof-of-address
POST /v1/users/{userId}/kyc/documents/biometric
```

Each takes a multipart file. JPEG or PNG.

```bash theme={null}
curl -X POST "$BASE_URL/v1/users/$USER_ID/kyc/documents/identification" \
  -H "x-api-key: $API_KEY" \
  -F "file=@id-front.jpg"
```

Then poll until the rail is active:

```http theme={null}
GET /v1/users/{userId}/onboarding/status
```

<Tip>
  Do not poll this in production. Subscribe to `onboarding.completed`, `onboarding.failed`, and `kyc.action_required` instead — see [Webhooks and events](/api-reference/webhooks).
</Tip>

## 9. Create the transfer proposal

Nothing moves yet. A proposal records intent.

```http theme={null}
POST /v1/users/{userId}/smart-wallets/{smartWalletId}/proposals
{
  "proposal": {
    "type": "TRANSFER",
    "toAddress": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
    "amount": "25.00",
    "currency": "CNGN",
    "description": "First send"
  }
}
```

Keep the proposal `id`.

<Note>
  Sending to `toUserId` instead of `toAddress` requires the recipient to already hold an active wallet **in that currency**. In a fresh sandbox they usually do not, so `toAddress` is the reliable choice for a first run.
</Note>

## 10. Approve

```http theme={null}
POST /v1/users/{userId}/smart-wallets/proposals/{proposalId}/approve
```

No body. `status` moves to `PENDING_SIGNATURES` once the threshold is met.

## 11. Sign and send

Fetch the payload:

```http theme={null}
GET /v1/users/{userId}/smart-wallets/proposals/{proposalId}/sign-payload
```

Sign `hashToSign` — a **raw 32-byte digest, with no prefix**. This is the opposite of step 3.

<CodeGroup>
  ```ts TypeScript theme={null}
  // signingKey.sign(), NOT signMessage() — no EIP-191 prefix here.
  const signature = wallet.signingKey.sign(signPayload.hashToSign).serialized
  ```

  ```python Python theme={null}
  from eth_account import Account

  signed = Account.unsafe_sign_hash(
      bytes.fromhex(sign_payload["hashToSign"][2:]),
      OWNER_PRIVATE_KEY,
  )
  signature = "0x" + signed.signature.hex()
  ```

  ```go Go theme={null}
  digest, _ := hex.DecodeString(strings.TrimPrefix(signPayload.HashToSign, "0x"))

  // Sign the digest directly — no TextHash, no prefix.
  sig, _ := crypto.Sign(digest, key)
  sig[64] += 27

  signature := "0x" + hex.EncodeToString(sig)
  ```

  ```php PHP theme={null}
  $digest = ltrim($signPayload['hashToSign'], '0x');

  // Sign the digest directly — no EIP-191 prefix.
  $sig = (new EC('secp256k1'))
      ->keyFromPrivate($ownerPrivateKey)
      ->sign($digest, ['canonical' => true]);

  $signature = '0x'
      . str_pad(gmp_strval($sig->r->toString(), 16), 64, '0', STR_PAD_LEFT)
      . str_pad(gmp_strval($sig->s->toString(), 16), 64, '0', STR_PAD_LEFT)
      . dechex($sig->recoveryParam + 27);
  ```
</CodeGroup>

Using the message-signing method here produces a signature that recovers to a different address, and the backend rejects it.

```http theme={null}
POST /v1/users/{userId}/smart-wallets/proposals/{proposalId}/sign
{
  "signature": "0x…"
}
```

### Expected result

`GET /v1/users/{userId}/smart-wallets/proposals/{proposalId}` reports `status` progressing `PENDING_SIGNATURES` → `COMPLETED`. The balance on the sending wallet drops by 25.

If the signature is rejected, check you used `signTransactionHash` and not `signMessage`, then work through [Why your signature is rejected](/api-reference/signing#why-your-signature-is-rejected).

## The two signatures, side by side

Worth keeping in front of you.

|                | Step 3 — owner proof                           | Step 11 — proposal                        |
| -------------- | ---------------------------------------------- | ----------------------------------------- |
| What you sign  | The challenge text                             | A 32-byte digest                          |
| EIP-191 prefix | **Yes**                                        | **No**                                    |
| TypeScript     | `wallet.signMessage(msg)`                      | `wallet.signingKey.sign(hash).serialized` |
| Python         | `Account.sign_message(encode_defunct(text=…))` | `Account.unsafe_sign_hash(digest, key)`   |
| Go             | `crypto.Sign(accounts.TextHash(msg), key)`     | `crypto.Sign(digest, key)`                |
| PHP            | prefix, then `Keccak::hash`, then `sign`       | `sign` the digest directly                |

Both produce a `0x`-prefixed 130-character hex signature. In Go and PHP, remember `v` comes back as `0`/`1` and Ethereum expects `27`/`28` — add 27, as the snippets do.

<Tip>
  Building a mobile client rather than a backend? `bmoni_embedded_sdk` wraps both of these as `signMessage` and `signTransactionHash`, with the key held in Android Keystore or the iOS Secure Enclave. See [SDK signing](/sdk-react-native/signing).
</Tip>

## Related

* [Sandbox test data](/api-reference/sandbox-test-data) — the personas and the matching rule.
* [Sign a proposal](/api-reference/signing) — the full signing reference and a reproducible test vector.
* [Errors and status codes](/api-reference/errors) — every error these calls return.
* [Webhooks and events](/api-reference/webhooks) — replacing the polling in step 8.
* [Request test tokens](/request-test-tokens) — funding the wallet before step 9.

***

*Last reviewed: 7 August 2026.*
