Skip to main content
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.
This is the server-side API walkthrough. If you are building a Flutter app with bmoni_embedded_sdk, start with the SDK quickstart instead — it covers the client side of the same lifecycle.

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. Use its details verbatim — verification matches them.
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.
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.

1. Create the user

Use the persona’s name and phone, converted to E.164.
Keep bmoniUserId from the response. It is the {userId} path parameter for every later call — not your own employee identifier.
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.

2. Submit the KYC profile

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.

3. Prove you control the owner address

Wallet creation requires the owner address to prove control first.
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.
Step 11 signs differently — see the comparison. 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

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 firstngnWalletAddress is required here.
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.
Each takes a multipart file. JPEG or PNG.
Then poll until the rail is active:
Do not poll this in production. Subscribe to onboarding.completed, onboarding.failed, and kyc.action_required instead — see Webhooks and events.

9. Create the transfer proposal

Nothing moves yet. A proposal records intent.
Keep the proposal id.
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.

10. Approve

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

11. Sign and send

Fetch the payload:
Sign hashToSign — a raw 32-byte digest, with no prefix. This is the opposite of step 3.
Using the message-signing method here produces a signature that recovers to a different address, and the backend rejects it.

Expected result

GET /v1/users/{userId}/smart-wallets/proposals/{proposalId} reports status progressing PENDING_SIGNATURESCOMPLETED. 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.

The two signatures, side by side

Worth keeping in front of you. 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.
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.

Last reviewed: 7 August 2026.