What is Moneva Platform?
Moneva Platform is the account layer for your product: onboard a customer with hosted KYC, give them named virtual fiat accounts, and move money in and out across 130+ countries on stablecoin rails. One REST API, one secret key, everything event-driven.
Features
How it works
Your backend talks to one REST API with one secret key. Underneath, Moneva orchestrates licensed banking, compliance and settlement partners. Their sessions, retries, reconciliation and corridor quirks are handled inside the platform and never surface in your integration.
Self-custody, always. Every customer balance settles to a wallet only they control: their own wallet or the embedded wallet of your app. Moneva never generates, stores, or has access to keys or funds.
KYC and regulatory compliance are owned by licensed partners. Your product embeds a hosted verification flow and reads back a clean status. You never store documents or make compliance decisions.
Status: the sandbox is live and self-serve today. Live mode is in development. Pricing: flat monthly platform fee plus a per-verification fee, with no basis points on volume from Moneva.
Next steps
Your first payout in 15 minutes
1. Create a customer
The customer starts in kyc_required. The wallet address is the customer's own; Moneva is self-custody only.
2. Register your webhook
Everything that happens next arrives as signed events. Register an endpoint once; the signing secret is returned only on creation.
No endpoint yet? GET /v1/events serves the same events as a queryable log.
3. Verify identity
Create a hosted KYC session and open the returned URL in your product. In test mode, force the outcome instead:
Your webhook receives customer.kyc.approved; the customer is active.
4. Give them an account
Provisioning is asynchronous: 202 now, then account.activated delivers the IBAN.
5. Simulate a deposit
6. Add a bank destination
Fetch the corridor's form schema, render it, submit the fields:
7. Quote, then transfer
Watch transfer.created then transfer.processing arrive on your webhook, then force the terminal state with POST /v1/test/transfers/{id}. That is the whole integration.
Next steps
Integrate your way
The ladder
sdk-ts): zero-dependency typed client, webhook verification, and the one-call wallet-session prompt. Tested end to end against the gateway; npm publish pending.sdk-react): useCorridors, useBeneficiarySchema, useProfileSchema, and a <BeneficiaryForm> that renders itself from the corridor schema. Bring your own styling.verifyWebhook, an Express handler, and a Next.js route helper, with timing-safe signature checks and typed events.The SDK, as implemented
Zero dependencies, field names identical to the REST API, every call typed:
import Moneva, { verifyWebhook } from "@moneva/platform"; const moneva = new Moneva(process.env.MONEVA_KEY); const customer = await moneva.customers.create({ email: "maria@example.com", first_name: "Maria", last_name: "Silva", country: "BR", wallet: { address: userWallet }, }); await moneva.walletSessions.prompt(customer.id, window.ethereum); // one call const quote = await moneva.quotes.create({ customer_id: customer.id, source: { currency: "USDC", amount: "100.00" }, destination_currency: "BRL" }); await moneva.transfers.create({ ... }, { idempotencyKey: order.id }); const event = verifyWebhook(rawBody, req.headers["moneva-signature"], secret);
For AI agents
Point a coding agent at /llms.txt and it gets the full picture: every docs page as text, the spec, the event catalog, and the public corridor and jurisdiction endpoints it can call without a key. The playground's copy-page button serves the same content per page for humans pasting into a chat.
One source of truth. The SDKs, components, docs, and agent bundles are all generated from openapi.yaml and the corridor registry. When a corridor or field changes, every integration path updates together.
Next steps
Authentication
Authorization: Bearer mk_test_4f8a... # test mode Authorization: Bearer mk_live_9c2d... # live mode (in development)
| Key prefix | Mode | Behavior |
|---|---|---|
mk_test_ | Test | Deterministic simulator. Instant, free, resettable. Data is fully isolated from live. |
mk_live_ | Live | In development Returns live_mode_disabled today. |
Secret keys grant full account access. Keep them server-side, never in client code, and rotate immediately if exposed.
Getting and rotating keys
- Test keys are issued when your organization is created. The self-serve developer dashboard is in development; until it ships, keys are provisioned by Moneva.
- Rotation: keys can be revoked and reissued without downtime; old keys stop authenticating immediately on revocation.
- Scope: a key belongs to one organization and one mode. Test and live data are fully isolated.
Auth errors
| Code | Status | When |
|---|---|---|
invalid_api_key | 401 | Missing, malformed, unknown or revoked key |
live_mode_disabled | 403 | Live key used while live mode is in development |
test_mode_only | 403 | Live key calling a /v1/test/* simulation endpoint |
Next steps
Customers & KYC
Create a customer
| Field | Notes |
|---|---|
email | Required. Unique per organization and mode; a duplicate returns 409 customer_exists with the existing id |
first_name / last_name | Required. Latin character sets; punctuation is normalized before hitting banking rails |
country | Required. ISO 3166-1 alpha-2 residence; drives jurisdiction rules below |
wallet.address | Required. The customer's own wallet: external or the integrator's embedded wallet (passkey smart accounts work well). Self-custody only; Moneva never generates or stores keys |
metadata | Optional, up to 20 string keys, returned on every read |
Lifecycle
| Status | Meaning | What to do |
|---|---|---|
kyc_required | Verification not started | Create a KYC session and open url |
kyc_pending | Under review | Wait for customer.kyc.approved or .rejected |
active | Verified | All money movement available |
rejected | Failed permanently | Do not retry; inform the user |
suspended | Compliance hold | Contact support |
Profile data
Before documents, KYC needs the "tell us about yourself" data: date of birth, address, occupation, and per-country extras. Two ways to collect it; mix them freely.
- Hosted (default): do nothing. The hosted verification flow collects whatever is missing.
- Your own form: render it from the per-country schema, exactly like bank forms, and submit via
PATCH. Your branding, our rules.
curl https://platform.moneva.io/v1/jurisdictions/BR/profile_schema
{
"country": "BR",
"fields": [
{ "key": "date_of_birth", "type": "date", "required": true, "sensitive": false },
{ "key": "occupation", "type": "text", "required": true, "sensitive": false },
{ "key": "address.line1", "type": "text", "required": true, "sensitive": false }, …,
{ "key": "address.country", "type": "text", "required": true, "sensitive": false }
]
}- Per-country extras are schema-driven: US adds
phoneandssn, Nigeria addsbvn. Your form never hardcodes any of it. - Sensitive ids are stored masked. SSN and BVN are forwarded for verification and kept as
kindplus last four only. - Identity fields lock after verification: changing them on an
activecustomer returns409 profile_locked. - Updates emit
customer.updated.
Wallet sessions
Self-custody is the only model: funds settle to a wallet only the customer controls. When an operation needs the customer's authorization, the gateway prepares a message, their wallet signs it client-side, and we verify. Two calls, one wallet prompt.
- Sessions last six days.
customer.wallet_session.activeconfirms activation; when an operation needs a fresh signature you get409 wallet_session_required. - Any EIP-1193 wallet works: external wallets or embedded ones. The SDK wraps both calls and the wallet prompt in one helper.
- EOAs and smart accounts both verify. A 65-byte ECDSA signature verifies offline; smart-contract accounts (Safe, Biconomy Nexus) verify on-chain via ERC-1271, including ERC-6492 counterfactual accounts that are not deployed yet.
- Moneva never holds keys. The platform prepares and verifies messages; signing happens only on the customer's device.
Supported jurisdictions
Which virtual accounts and payouts a customer can use depends on their verified residency. The rules below are reference data from GET /v1/jurisdictions; per-customer eligibility is enforced automatically at KYC.
Create the customer with any of these residence countries; everything outside the list is rejected at onboarding with a clear error, before any KYC session is started.
Hosted verification
Open the URL in a webview or new tab. Progress arrives as customer.kyc.* events; you never receive or store identity documents.
Next steps
Virtual accounts
Create an account
- KYC first. The customer must be
active; otherwise422 kyc_not_passed. - One account per currency. A duplicate returns
409 account_existswith the existing id. - Statuses:
provisioning→active(details ready) ·closed(provisioning failed or retired). - Eligibility follows the customer's residency; see Supported jurisdictions.
Provisioning is asynchronous
Account creation returns 202 with status provisioning. When the account is ready, typically within seconds, account.activated delivers the full deposit details. Never poll; consume the event.
{
"object": "account",
"currency": "EUR",
"status": "active",
"deposit_details": {
"iban": "DE89 3704 0044 0532 0130 00",
"bic": "…",
"bank_name": "…"
}
}| Currency | Deposit details |
|---|---|
| EUR | IBAN + BIC (SEPA) |
| GBP | Account number + sort code (Faster Payments) |
| USD | Account + routing number (ACH) |
| NGN | Local account number |
Deposits
Inbound fiat produces two events: account.deposit.received when the transfer is detected, and account.deposit.settled when converted proceeds land at the customer's settlement address.
Settlement is always USDC. Whatever currency arrives on a virtual account, the customer's balance is credited in USDC after conversion. Payouts then draw from that USDC balance. For NGN, funding flows mainly through stablecoins today; the local rail complements the crypto path.
Next steps
Corridors & bank forms
The beneficiary schema
curl https://platform.moneva.io/v1/corridors/BRL/beneficiary_schema
{
"currency": "BRL",
"transfer_methods": ["pix"],
"fields": [
{ "key": "account_holder_name", "type": "text", "required": true },
{ "key": "pix_key", "type": "text", "required": true },
{ "key": "tax_id", "type": "digits", "required": true }
]
}Render your bank form from fields. On submit, the same registry entry that produced the schema validates the values, so the form you rendered and the validation applied can never disagree. Rejections come back per field with stable codes.
The corridor object
curl https://platform.moneva.io/v1/corridors
{{
"data": [
{{ "currency": "BRL", "country": "BR", "transfer_methods": ["pix"],
"minimum_usd": "5.00", "status": "active" }},
…
]
}}Public reference data. GET /v1/corridors, the beneficiary schemas, and GET /v1/jurisdictions need no API key and allow browser requests, so coverage pages and pre-signup flows can read them directly.
Currency availability
Live from GET /v1/jurisdictions and GET /v1/corridors: which currencies can come in as virtual-account deposits and which can go out as payouts.
Payout methods
Not every corridor is a bank account. The beneficiary schema tells you how each destination is addressed, so your form always collects the right thing:
The transfer_methods array on each corridor plus the served field set encode all of this; your integration never branches on method type by hand.
Normalization included
Common real-world failures are absorbed before they reach the banking rails: IBANs are uppercased and de-spaced, names are checked against the character sets the rails actually accept, and per-corridor formats are enforced up front.
Corridor availability and live minimums come from GET /v1/corridors. It reflects real capability, not a static table.
Next steps
Quotes
Create a quote
{
"id": "qt_8d3f6a",
"object": "quote",
"rate": "5.067400",
"fees": [{ "type": "transfer_fee", "amount": "1.40", "currency": "USD" }],
"net_receivable": { "amount": "535.40", "currency": "BRL" },
"minimum_usd": "5.00",
"expires_at": "2026-07-23T00:41:02Z"
}Quotes price payouts from the customer's USDC balance. Inbound deposits need no quote; they convert automatically at settlement.
How the price composes
One formula, no hidden components: net_receivable = (source amount − fees) × rate. The rate is mid-market; Moneva's margin is the visible transfer_fee line. For the example above: (100.00 − 1.40) × 5.0674 = 535.40 BRL, delivered exactly.
- Net receivable is exact. What the quote says is what the beneficiary receives.
- Minimums are live. Amounts below
minimum_usdare rejected withamount_below_minimumand the current floor indetails. - Quotes expire. Execute within
expires_at(60 seconds) or create a new one.
Fields
| Field | Meaning |
|---|---|
rate | Mid-market FX rate, source to destination, six decimals |
fees[] | Itemized fee lines; today a single transfer_fee in USD |
net_receivable | Exact amount and currency the beneficiary receives |
minimum_usd | Live corridor floor at quote time |
expires_at | Execution deadline; the transfer must be created before it |
Errors
| Code | When |
|---|---|
amount_below_minimum | Amount under the live corridor floor; details.minimum_usd carries it |
unsupported_corridor | No active corridor for destination_currency |
unsupported_source_currency | Quotes price from USDC/USD today |
quote_expired | Raised at transfer creation when expires_at has passed |
quote_beneficiary_mismatch | Quote currency differs from the beneficiary's corridor |
Using a quote in a transfer
- Pass
quote_idonPOST /v1/transfers; the quote's rate, fees and net receivable are frozen onto the transfer at creation and never rewritten by settlement. - The quote's destination currency must match the beneficiary's corridor, or the transfer is rejected with
quote_beneficiary_mismatch. - Display the quote to your user, then execute inside the 60-second window; on expiry, re-quote and re-confirm.
Next steps
Transfers
Create a transfer
{
"id": "tr_4c7m9k",
"object": "transfer",
"type": "remittance",
"status": "created",
"destination": { "currency": "BRL", "amount": "535.40", "beneficiary_id": "ben_5j8w2e" },
"fees": [{ "type": "transfer_fee", "amount": "1.40", "currency": "USD" }],
"timeline": [{ "status": "created", "at": "…" }]
}List with GET /v1/transfers?customer_id=…&status=…; retrieve a single transfer for the full timeline.
Types
| Type | Meaning |
|---|---|
payout | Bank/method payout to a beneficiary in the account's currency zone |
remittance | Cross-currency payout against a quote |
deposit | Inbound funding recorded by the platform (never created by you) |
Idempotency, required
Every transfer creation requires an Idempotency-Key header. Retries with the same key return the original result; the same key with a different payload is rejected with 409 idempotency_key_reused. Network failures can never double-pay.
Frozen quote values
The quote's rate, fees and net receivable are copied onto the transfer at creation. What you display to your user is what the record says forever; settlement never rewrites history.
States
Every transition is its own event with the full object attached, and the timeline array records each step with timestamps. failed always carries a machine-readable failure_reason and funds return to source.
Errors
| Code | When |
|---|---|
idempotency_key_required | Missing Idempotency-Key header |
idempotency_key_reused | Same key, different payload |
idempotency_in_progress | Original request still processing; retry shortly |
kyc_not_passed | Customer is not active |
beneficiary_not_owned | Beneficiary belongs to a different customer |
quote_expired | Quote's expires_at has passed |
quote_beneficiary_mismatch | Quote currency differs from the beneficiary's corridor |
Next steps
Events & webhooks
Verify signatures
const [t, v1] = header.split(",").map(p => p.split("=")[1]); const expected = crypto .createHmac("sha256", endpointSecret) .update(`${t}.${rawBody}`) .digest("hex"); if (v1 !== expected || tooOld(t)) reject(); // 5-minute replay window
The signature arrives in the Moneva-Signature header as t=<unix>,v1=<hmac>. The endpoint secret is shown once at registration.
Delivery semantics
- At-least-once. Deduplicate on
event.id. - Every transition delivered. Intermediate states are never collapsed away by retries.
- Retries back off over 72 hours; persistently failing endpoints are disabled and you are notified.
- Recovery:
GET /v1/eventsis the durable log; reconcile any gap from it.
Manage endpoints
- Omit
event_typesto receive everything; list and delete endpoints withGET/DELETE /v1/webhook_endpoints/{id}. - Multiple endpoints per mode are supported; each has its own secret.
Reading the log directly
Cursor pagination via next_cursor; events are retained 90 days. The log is the recovery path after any webhook outage.
Event catalog
| Event | Fired when |
|---|---|
customer.created | Customer record created |
customer.kyc.pending | Verification started |
customer.kyc.approved | Customer is active |
customer.kyc.rejected | Verification failed permanently |
customer.wallet_session.active | Customer-signed wallet session active |
customer.updated | Profile or metadata updated |
account.activated | Deposit details ready |
account.deposit.received | Inbound fiat detected |
account.deposit.settled | Proceeds settled to the customer |
beneficiary.activated | Bank destination payable |
transfer.created / .processing | Transfer accepted / funds in flight |
transfer.completed | Fiat delivered |
transfer.failed / .returned | Terminal failure / returned by receiving bank |
Next steps
Email notifications
Your users expect to hear "your transfer arrived" from you, not from us. Two ways to make that happen, and both are first-class:
| Approach | How | When to choose it |
|---|---|---|
| Build your own | Consume the webhook events and notify through your own stack: email, push, SMS, in-app | You already have notification infrastructure, or want channels beyond email |
| Platform-sent emails | Enable branded emails below; the platform sends on your behalf, in your brand, from your domain | You want status emails live today with zero email infrastructure |
Everything on this page is optional and off by default. The email system is a convenience layered on the same events you receive anyway; nothing here is required, and skipping it costs you no functionality. Mix freely: many integrators send their own marketing and product mail but let the platform handle transactional status receipts.
Configure once
- Off by default. Nothing is sent until you flip
branded_emails. - Pick your events. Leave
event_typesempty to email on every notifiable event, or select a subset. - Your brand carries. Logo, accent color, sender name, reply-to, and footer are all yours.
Send from your own domain
By default mail leaves from the shared domain. Add your own domain and verify it via DNS, and every email is sent from your address: the end user never sees anything but your brand, down to the sender domain.
The response carries the DNS records to add at your DNS host:
| Type | Host | Value |
|---|---|---|
TXT | _moneva.acme.com | moneva-verify=tok_… |
CNAME | mnv1._domainkey.acme.com | mnv1.dkim.moneva.io |
CNAME | mnv2._domainkey.acme.com | mnv2.dkim.moneva.io |
CNAME | bounce.acme.com | bounce.moneva.io |
- Then set your address:
PATCH /v1/notification_settingswithfrom_email: "no-reply@acme.com". Setting it requires the domain to be added first. - Fail-safe: an unverified domain is never used; until verification passes, sends fall back to the shared domain.
notification.domain.verifiedfires when DNS checks out. - Test mode verifies immediately so you can build the full flow against the sandbox; live mode resolves your real DNS records.
What gets sent
| Event | |
|---|---|
customer.kyc.approved | You're verified |
customer.kyc.rejected | We couldn't verify your identity |
account.activated | Your account is ready |
account.deposit.settled | Your deposit has settled |
transfer.completed | Your transfer was delivered |
transfer.failed | Your transfer could not be completed |
Fully custom templates
The default layout carries your brand tokens. When that is not enough, replace it entirely: your own subject line and your own complete HTML per event. Your design system, your copy, your language.
- Variables:
{{sender_name}}everywhere; money events add receipt values like{{amount_sent}},{{delivered}},{{fee}},{{reference}},{{amount_received}},{{settled}}. Values are HTML-escaped on insert; unknown variables stay visible so mistakes surface in preview. - Per event: override only the emails you care about; the rest keep the default layout.
DELETEthe template to revert. - Preview works the same:
GET /previewrenders your custom template with sample values before anything is enabled.
Preview before enabling
Returns the rendered subject and html with your current branding applied, so you can review every template before a single mail goes out. Try it below: the same template, rendered live with your brand.
Every send is observable
Each qualifying send appears in your event stream as notification.email.sent with the triggering event_type, the recipient, and the subject. In test mode no real mail leaves; the events fire exactly as they will in production, so you can build and verify the full loop against the sandbox.
Errors
{
"code": "amount_below_minimum",
"message": "Minimum for BRL is $5.00.",
"details": { "minimum_usd": "5.00" }
}| Code | Status | Meaning |
|---|---|---|
invalid_api_key | 401 | Missing, malformed or revoked key |
live_mode_disabled | 403 | Live keys not yet enabled |
resource_not_found | 404 | Unknown id for this org and mode |
idempotency_key_required | 400 | Money POST without an Idempotency-Key |
idempotency_key_reused | 409 | Same key, different payload |
kyc_not_passed | 422 | Customer must be active first |
validation_failed | 422 | Field-level failures in fields[] |
amount_below_minimum | 422 | Below the live corridor floor |
quote_expired | 422 | Create a fresh quote |
unsupported_corridor | 422 | No active corridor for that currency |
customer_exists | 409 | Duplicate email within your org and mode |
account_exists | 409 | Customer already has an account in that currency |
beneficiary_not_owned | 403 | Beneficiary belongs to a different customer |
quote_beneficiary_mismatch | 422 | Quote and beneficiary currencies differ |
idempotency_in_progress | 409 | Original request still processing |
test_mode_only | 403 | Simulation endpoint called with a live key |
upstream_validation | 422 | The banking provider rejected a field value |
upstream_error | 502 | Upstream provider fault; safe to retry |
route_not_found | 404 | Unknown path |
internal_error | 500 | Unexpected fault; report with your Request-Id |
Next steps
Test mode
Real sandboxes at banking providers are slow, gated and periodically wiped. The simulator exists so your CI and local development never depend on any of that, while behaving exactly like the live API: same objects, same events, same async provisioning.
| Endpoint | Simulates |
|---|---|
POST /v1/test/customers/{id}/kyc | KYC outcome: approved, rejected, resubmission_required |
POST /v1/test/accounts/{id}/deposits | Inbound fiat deposit with sender name |
POST /v1/test/transfers/{id} | Terminal outcome: completed, failed, returned |
Simulation endpoints only accept test keys. Calling them with a live key returns test_mode_only.
Examples
Every simulation emits the same events as the real transition, so your webhook handling is exercised end to end. Test data is isolated per organization and never touches live records.
Next steps
Endpoint reference
Customers
| POST | /customers | Create a customer |
| GET | /customers · /customers/{id} | List / retrieve |
| POST | /customers/{id}/kyc_session | Hosted verification URL |
| POST | /customers/{id}/wallet_sessions · /verify | Client-signed wallet sessions |
| GET | /jurisdictions/{cc}/profile_schema | Per-country profile fields Public |
| PATCH | /customers/{id} | Update profile ("tell us about yourself") |
Accounts & beneficiaries
| POST | /customers/{id}/accounts | Provision a virtual account |
| GET | /customers/{id}/accounts | List accounts |
| POST | /customers/{id}/beneficiaries | Create a bank destination |
| GET | /customers/{id}/beneficiaries | List beneficiaries |
Corridors, quotes, transfers
| GET | /corridors | Live corridor availability + minimums Public |
| GET | /jurisdictions | Residency rules + currency availability Public |
| GET | /corridors/{ccy}/beneficiary_schema | Bank form schema Public |
| POST | /quotes | Deterministic pricing |
| POST | /transfers | Execute (Idempotency-Key required) |
| GET | /transfers · /transfers/{id} | List / retrieve with timeline |
Events & webhooks
| GET | /events · /events/{id} | Durable 90-day event log |
| POST | /webhook_endpoints | Register an endpoint (secret shown once) |
| GET | /webhook_endpoints | List endpoints |
| DEL | /webhook_endpoints/{id} | Delete an endpoint |
Test mode
| POST | /test/customers/{id}/kyc | Force KYC outcome |
| POST | /test/accounts/{id}/deposits | Simulate deposit |
| POST | /test/transfers/{id} | Force transfer outcome |
Build your flow
Playground
API console
Every documented request is runnable. Pick an endpoint, adjust the body, and send it against the sandbox: real gateway, real status codes, real payloads. Use your mk_test_ key; keep live keys server-side.
https://platform.moneva.io