# Moneva Platform - Developer Documentation One API key for customer onboarding with KYC, virtual fiat accounts, and payouts to 130+ countries on stablecoin rails. Self-custody only. Machine-readable spec: openapi.yaml · Event catalog: see Events section below. --- # What is Moneva Platform? KYC onboarding Virtual accounts Deposits FX Payouts Remittance Webhooks Settlement 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 Customers & KYC One object for your end user. Hosted verification, webhook-driven status, no document handling on your side. Virtual accounts Named USD, EUR, GBP and NGN deposit details per customer. Every deposit converts and settles as USDC. Payouts & remittance Payouts to 130+ countries: bank rails like SEPA and NIBSS, instant systems like Pix, mobile money like M-Pesa, plus SWIFT reach. Bank forms as an API Per-corridor beneficiary schemas served by the API. Render the exact form; validation can never drift. Signed webhooks Every state transition is an HMAC-signed event with retries and a queryable 90-day log. Nothing is dropped. Instant sandbox Self-serve test keys against a deterministic simulator. Force any KYC outcome, deposit or transfer state. Any-crypto funding In development Customers fund with any supported token on any chain; the built-in swap layer settles it as USDC before payout. ## 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 Quickstart First payout in 15 minutes, end to end against the sandbox Customers & KYC The onboarding lifecycle and hosted verification Events & webhooks How every state change reaches your system Corridors Bank form schemas and live payout coverage --- # Your first payout in 15 minutes ## 1. Create a customer ``` curl -X POST https://platform.moneva.io/v1/customers \ -H "Authorization: Bearer mk_test_..." \ -d '{ "email": "maria@example.com", "first_name": "Maria", "last_name": "Silva", "country": "BR", "wallet": { "address": "0x4F0e5A2bC81dE93f7A61a2b3C4d5E6f708192A1a" } }' ``` 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. ``` curl -X POST https://platform.moneva.io/v1/webhook_endpoints \ -H "Authorization: Bearer mk_test_..." \ -d '{ "url": "https://api.your-app.com/moneva/hooks" }' ``` 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: ``` curl -X POST https://platform.moneva.io/v1/test/customers/{id}/kyc \ -H "Authorization: Bearer mk_test_..." \ -d '{ "outcome": "approved" }' ``` Your webhook receives customer.kyc.approved; the customer is active. ## 4. Give them an account ``` curl -X POST https://platform.moneva.io/v1/customers/{id}/accounts \ -H "Authorization: Bearer mk_test_..." \ -d '{ "currency": "EUR" }' ``` Provisioning is asynchronous: 202 now, then account.activated delivers the IBAN. ## 5. Simulate a deposit ``` curl -X POST https://platform.moneva.io/v1/test/accounts/{id}/deposits \ -H "Authorization: Bearer mk_test_..." \ -d '{ "amount": "250.00", "sender_name": "ACME GmbH" }' ``` ## 6. Add a bank destination Fetch the corridor's form schema, render it, submit the fields: ``` curl https://platform.moneva.io/v1/corridors/BRL/beneficiary_schema ``` ``` curl -X POST https://platform.moneva.io/v1/customers/{id}/beneficiaries \ -H "Authorization: Bearer mk_test_..." \ -d '{ "currency": "BRL", "account_details": { "account_holder_name": "Maria Silva", "pix_key": "maria@example.com", "tax_id": "12345678901" } }' ``` ## 7. Quote, then transfer ``` curl -X POST https://platform.moneva.io/v1/quotes \ -H "Authorization: Bearer mk_test_..." \ -d '{ "customer_id": "{id}", "source": { "currency": "USDC", "amount": "100.00" }, "destination_currency": "BRL" }' ``` ``` curl -X POST https://platform.moneva.io/v1/transfers \ -H "Authorization: Bearer mk_test_..." \ -H "Idempotency-Key: order-8471" \ -d '{ "customer_id": "{id}", "type": "remittance", "quote_id": "qt_...", "beneficiary_id": "ben_...", "source": { "currency": "USDC", "amount": "100.00" } }' ``` 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 Authentication Keys, modes and safety rules Webhooks Verify signatures, handle retries --- # Integrate your way ## The ladder REST API Live Curl-first, OpenAPI as the source of truth. Idempotency, signed webhooks, stable error codes. Download the spec. TypeScript SDK Beta In the repo today (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. React components & hooks Beta Headless bindings (sdk-react): useCorridors, useBeneficiarySchema, useProfileSchema, and a that renders itself from the corridor schema. Bring your own styling. Webhook adapters Beta Shipped inside the SDK: verifyWebhook, an Express handler, and a Next.js route helper, with timing-safe signature checks and typed events. AI agents & LLMs Live Machine-readable everything: llms.txt, llms-full.txt (the whole docs as plain text), the OpenAPI spec, and key-less public reference endpoints. ## 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 Quickstart REST path, first payout in 15 minutes Endpoint reference The full v1 surface --- # 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 Errors Stable machine-readable codes Quickstart Make your first call --- # Customers & KYC ## Create a customer ``` curl -X POST https://platform.moneva.io/v1/customers \ -H "Authorization: Bearer mk_test_..." \ -d '{ "email": "maria@example.com", "first_name": "Maria", "last_name": "Silva", "country": "BR", "wallet": { "address": "0x4F0e5A2bC81dE93f7A61a2b3C4d5E6f708192A1a" }, "metadata": { "your_user_id": "u_8841" } }' ``` | 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 created→ kyc_required→ kyc_pending→ active | 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 } ] } ``` ``` curl -X PATCH https://platform.moneva.io/v1/customers/{id} \ -H "Authorization: Bearer mk_test_..." \ -d '{ "date_of_birth": "1990-03-31", "occupation": "Engineer", "address": { "line1": "Av. Paulista 1000", "city": "Sao Paulo", "postal_code": "01310-100", "country": "BR" } }' ``` - Per-country extras are schema-driven: US adds phone and ssn, Nigeria adds bvn. Your form never hardcodes any of it. - Sensitive ids are stored masked. SSN and BVN are forwarded for verification and kept as kind plus last four only. - Identity fields lock after verification: changing them on an active customer returns 409 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. ``` curl -X POST https://platform.moneva.io/v1/customers/{id}/wallet_sessions \ -H "Authorization: Bearer mk_test_..." ``` ``` curl -X POST https://platform.moneva.io/v1/customers/{id}/wallet_sessions/verify \ -H "Authorization: Bearer mk_test_..." \ -d '{ "signature": "0x\u2026" }' ``` - Sessions last six days. customer.wallet_session.active confirms activation; when an operation needs a fresh signature you get 409 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 ``` curl -X POST https://platform.moneva.io/v1/customers/{id}/kyc_session \ -H "Authorization: Bearer mk_test_..." ``` Open the URL in a webview or new tab. Progress arrives as customer.kyc.* events; you never receive or store identity documents. ## Next steps Accounts Deposit details for verified customers Test mode Force KYC outcomes instantly --- # Virtual accounts ## Create an account ``` curl -X POST https://platform.moneva.io/v1/customers/{id}/accounts \ -H "Authorization: Bearer mk_test_..." \ -d '{ "currency": "EUR" }' ``` - KYC first. The customer must be active; otherwise 422 kyc_not_passed. - One account per currency. A duplicate returns 409 account_exists with 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 Where money can go out Events Deposit notifications in practice --- # 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: Bank rails SEPA, Faster Payments, ACH, NIBSS, SPEI, IMPS, Zengin and SWIFT. Addressed by IBAN, account and routing numbers, CLABE, CBU or IFSC. Instant payment systems Pix and similar alias-based rails. Addressed by a key such as an email, phone number or tax id, not an account number. Mobile money M-Pesa, MTN MoMo and similar. Addressed by a mobile number plus provider. Wallets GCash and similar. Addressed by a wallet number and provider. 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 Price a payout deterministically Transfers Execute against a quote --- # Quotes ## Create a quote ``` curl -X POST https://platform.moneva.io/v1/quotes \ -H "Authorization: Bearer mk_test_..." \ -d '{ "customer_id": "cus_9f3k2m1x", "source": { "currency": "USDC", "amount": "100.00" }, "destination_currency": "BRL" }' ``` ``` { "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_usd are rejected with amount_below_minimum and the current floor in details. - 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_id on POST /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 Freeze the quote into execution Corridors Live minimums and availability --- # Transfers ## Create a transfer ``` curl -X POST https://platform.moneva.io/v1/transfers \ -H "Authorization: Bearer mk_test_..." \ -H "Idempotency-Key: order-8471" \ -d '{ "customer_id": "cus_9f3k2m1x", "type": "remittance", "quote_id": "qt_8d3f6a", "beneficiary_id": "ben_5j8w2e", "source": { "currency": "USDC", "amount": "100.00" } }' ``` ``` { "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 created→ processing→ completed also terminal: failed returned 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 Consume transfer state changes Test mode Force any terminal outcome --- # 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=,v1=. 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/events is the durable log; reconcile any gap from it. ## Manage endpoints ``` curl -X POST https://platform.moneva.io/v1/webhook_endpoints \ -H "Authorization: Bearer mk_test_..." \ -d '{ "url": "https://api.your-app.com/hooks", "event_types": [ "transfer.completed" ] }' ``` - Omit event_types to receive everything; list and delete endpoints with GET / DELETE /v1/webhook_endpoints/{id}. - Multiple endpoints per mode are supported; each has its own secret. ## Reading the log directly ``` curl https://platform.moneva.io/v1/events?type=transfer.completed&limit=50 \ -H "Authorization: Bearer mk_test_..." ``` 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 Branded status emails with zero email infrastructure --- # 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 ``` curl -X PATCH https://platform.moneva.io/v1/notification_settings \ -H "Authorization: Bearer mk_test_..." \ -d '{ "branded_emails": true, "sender_name": "ACME Pay", "logo_url": "https://acme.example/logo.png", "brand_color": "#0E8C82", "reply_to": "support@acme.example", "event_types": [ "customer.kyc.approved", "account.deposit.settled", "transfer.completed" ] }' ``` - Off by default. Nothing is sent until you flip branded_emails. - Pick your events. Leave event_types empty 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. ``` curl -X POST https://platform.moneva.io/v1/notification_settings/domains \ -H "Authorization: Bearer mk_test_..." \ -d '{ "domain": "acme.com" }' ``` 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 | ``` curl -X POST https://platform.moneva.io/v1/notification_settings/domains/{id}/verify \ -H "Authorization: Bearer mk_test_..." ``` - Then set your address: PATCH /v1/notification_settings with from_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.verified fires 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 | Email | | 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. ``` curl -X PUT https://platform.moneva.io/v1/notification_settings/templates/transfer.completed \ -H "Authorization: Bearer mk_test_..." \ -d '{ "subject": "{{sender_name}}: your money arrived", "html": "Delivered {{delivered}} \u00b7 ref {{reference}}" }' ``` - 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. DELETE the template to revert. - Preview works the same: GET /preview renders your custom template with sample values before anything is enabled. ## Preview before enabling ``` curl https://platform.moneva.io/v1/notification_settings/preview?event_type=transfer.completed \ -H "Authorization: Bearer mk_test_..." ``` 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. Event Sender name From address no-reply@acme.exampleyour verified domain no-reply@notifications.moneva.ioshared fallback Brand color This is the default template GET /preview returns; logo, color, sender, domain, and footer are yours. Need more? Replace the whole layout per event with your own HTML via PUT /templates/{event_type}. ACME Pay To: sam@example.com ## 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 Stable codes end to end --- # 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 Endpoint reference The full v1 surface --- # 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 ``` curl -X POST https://platform.moneva.io/v1/test/customers/{id}/kyc \ -H "Authorization: Bearer mk_test_..." \ -d '{ "outcome": "approved" }' ``` ``` curl -X POST https://platform.moneva.io/v1/test/accounts/{id}/deposits \ -H "Authorization: Bearer mk_test_..." \ -d '{ "amount": "250.00", "sender_name": "ACME GmbH" }' ``` ``` curl -X POST https://platform.moneva.io/v1/test/transfers/{id} \ -H "Authorization: Bearer mk_test_..." \ -d '{ "outcome": "failed" }' ``` 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 Quickstart Run the full lifecycle now --- # 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 | Moneva Platform · API v1 draft · Sandbox live, self-serve --- # Build your flow EUR deposit → BRL Pix USDC → NGN payout GBP deposit → EUR SEPA Any crypto → BRL Pix Deposit only Choose a source and a destination to assemble the flow. Source Destination --- # Playground Select flow Deposit Fund via bank transfer Payout Bank payout abroad Cards In development P2P In development Crypto in Any token → USDC · In development Customer residency Payout corridor All Bank Instant Mobile Wallet Configure auth Passkey Email Google Apple Notifications Branded emails · ACME Pay White-label status emails to the end user, sent by the platform in your brand. Toggling calls PATCH /v1/notification_settings; each send appears as a notification.email.sent event. 9:41 API callsReset --- # 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. POSThttps://platform.moneva.io Path Replace {placeholders} with real ids from previous responses. Body paramsEdit as JSON Credentials Header Base URL Test keys only; stored in this browser. Public reference endpoints need no key. Request ``` ``` Try it Response ``` Try it to see the live response here. ```