OAuth
The OAuth flow lets you offer Monerium services without managing compliance infrastructure. Users are redirected to a Monerium portal to complete KYC/KYB and authorize your application; you handle the product experience, Monerium handles the regulation.
Create an application
- Go to sandbox.monerium.dev and select Developers in the sidebar.
- Click Create application and select OAuth.
- Enter your application name and confirm.
- Copy the CLIENT ID from the credentials screen; you'll need it in the authorization flow below.
When you're ready for production, repeat this at monerium.app. See Going live for the full checklist.
Integration flow
- Authorize: Redirect the user to Monerium. They log in or sign up, complete KYC/KYB, and grant your app access. You receive an authorization code, exchange it for tokens, and store the refresh token.
- Link a wallet: Associate a blockchain address with the user's profile.
- Issue an IBAN: Provision a dedicated IBAN once the profile is approved and a wallet is linked.
- Send and receive payments: Place redeem orders and monitor status via polling or on-chain events.
Authorization
The OAuth flow uses Authorization Code with PKCE. Even though PKCE removes the need for a client secret in the browser, your integration still requires a backend. The Monerium API does not support browser-based CORS for authenticated requests, and bearer tokens must be kept server-side; exposing them to the browser creates real risk (XSS, token leaks, misuse).
The intended setup:
- Frontend: initiates the flow, redirects the user to Monerium, receives the authorization code on callback, and passes it to your backend.
- Backend: exchanges the code for tokens, stores them securely, and makes all Monerium API calls. Your frontend talks to your backend, not to Monerium directly.
1. Generate a PKCE challenge
On your backend, generate a random code_verifier and derive a code_challenge from it before redirecting. Persist the verifier in your database or cache alongside the state; you need it when exchanging the code for tokens on the callback.
import crypto from 'crypto';
function generateCodeVerifier() {
return crypto.randomBytes(64).toString('base64url');
}
function generateCodeChallenge(verifier) {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// Persist alongside the state in step 2; needs to survive until the callback
2. Redirect to Monerium
Your backend builds the authorization URL and redirects the user to the Monerium portal. What happens there depends on whether the user has an account:
- Login or sign up: the user logs in with their Monerium username and password, or creates a new account.
- KYC/KYB: new users complete identity verification before they can use their account. Returning users who are already verified skip this step. See the KYC Guide: Individuals and KYB Guide: Corporates for what is required, allowed, and prohibited.
- Authorization: the user is shown a consent screen listing what your application is requesting access to (profile details and payment data) and accepts or cancels.
Once the user accepts, they are redirected to your redirect_uri with an authorization code in the query string.
The authorization screen displays your app name, logo, and a header color, all configured in your application settings. You can also link to your terms of service and privacy policy, which are shown on the consent screen.
const state = crypto.randomBytes(16).toString('hex');
// Persist state and code_verifier, keyed by session so you can retrieve them on callback
await db.pkce.store({ sessionId, state, codeVerifier });
const params = new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
redirect_uri: 'https://your-app.com/callback',
state,
});
res.redirect(`https://api.monerium.dev/auth?${params}`);
Optional parameters:
| Parameter | Description |
|---|---|
email | Pre-fill the email field. |
skip_kyc | Skip KYC steps in sandbox during testing. |
3. Handle the callback
Monerium redirects to your redirect_uri with ?code=<authorization_code>. Verify state before proceeding.
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== await db.pkce.getState(sessionId)) {
return res.status(400).send('State mismatch, possible CSRF attack');
}
// Pass the code to the token exchange (step 4)
});
4. Exchange the code for tokens
Exchange the authorization code for an access_token and refresh_token. This call happens on your backend. Use the Authorization Code Flow client_id. Your app has two separate client IDs, one per flow.
const response = await fetch('https://api.monerium.dev/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: 'YOUR_AUTH_CODE_CLIENT_ID',
code,
code_verifier: await db.pkce.get(state), // retrieve the verifier you stored in step 2
redirect_uri: 'https://your-app.com/callback',
}),
});
const { access_token, refresh_token, expires_in } = await response.json();
// Store tokens server-side in your database or encrypted cache (e.g. Redis)
// Never send them to the frontend
await db.tokens.upsert({ userId, access_token, refresh_token });
5. Refresh the access token
Access tokens expire after 1 hour. Use the refresh token to get a new one without re-authenticating the user. Refresh tokens rotate on each use; always store the latest one.
const response = await fetch('https://api.monerium.dev/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: 'YOUR_AUTH_CODE_CLIENT_ID',
refresh_token: req.session.refreshToken,
}),
});
const { access_token, refresh_token: newRefreshToken } = await response.json();
await db.tokens.upsert({ userId, access_token, refresh_token: newRefreshToken });
Sign-In with Ethereum (SIWE)
Existing Monerium customers with a linked wallet can skip the Monerium portal and authenticate via a wallet signature. The signature is produced client-side, but the /auth request and all subsequent token handling happen on your backend.
// 1. Frontend: build and sign the EIP-4361 message with the user's wallet
const message = [
`${domain} wants you to sign in with your Ethereum account:`,
address,
'',
`Allow ${appName} to access my data on Monerium`,
'',
`URI: ${redirectUri}`,
'Version: 1',
`Chain ID: 1`,
`Nonce: ${nonce}`,
`Issued At: ${issuedAt}`,
`Expiration Time: ${expirationTime}`,
'Resources:',
'- https://monerium.com/siwe',
`- ${privacyPolicyUrl}`,
`- ${termsOfServiceUrl}`,
].join('\n');
const signature = await signer.signMessage(message);
// 2. Frontend: send message + signature to your backend
await fetch('/api/siwe-auth', {
method: 'POST',
body: JSON.stringify({ message, signature }),
});
// 3. Backend: POST to /auth and handle the token exchange
const response = await fetch('https://api.monerium.dev/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: 'YOUR_CLIENT_ID',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
authentication_method: 'siwe',
message,
signature,
redirect_uri: redirectUri,
}),
});
// Redirected to redirect_uri with ?code=..., exchange for tokens as in step 4
expiration_timeis required (optional in the EIP-4361 spec but required by Monerium)resourcesmust includehttps://monerium.com/siweand your app's privacy policy and terms URLsnoncemust be unique per requestchain_idshould match a chain the user has linked with Monerium
Link wallet
Link a blockchain address to a profile to enable IBAN issuance and payments. Linking requires a cryptographic proof of ownership: the customer signs a fixed message and your app submits the signature.
The fixed message to sign:
I hereby declare that I am the address owner.
- EOA
- Smart Contract Wallet — Off-chain
- Smart Contract Wallet — On-chain
Sign the message with the wallet's private key using eth_sign / personal_sign. This produces a standard 65-byte ECDSA signature.
import { ethers } from 'ethers';
const message = 'I hereby declare that I am the address owner.';
const wallet = new ethers.Wallet(privateKey);
const signature = await wallet.signMessage(message);
const response = await fetch('https://api.monerium.dev/addresses', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
profile: profileId,
address: wallet.address,
chain: 'ethereum', // or 'gnosis', 'polygon'
message,
signature,
}),
});
// 201 — address linked
Each owner of the contract wallet signs the message off-chain. Once the threshold is met, assemble the combined signature and submit it. Monerium verifies via EIP-1271 by calling isValidSignature(messageHash, signature) on the contract.
The example uses Safe's protocol kit, but any EIP-1271-compatible smart contract wallet follows the same pattern.
import Safe, {
buildSignatureBytes,
SigningMethod,
} from '@safe-global/protocol-kit';
const message = 'I hereby declare that I am the address owner.';
// Owner 1 signs
let kit = await Safe.init({ provider, signer: ownerPrivateKey1, safeAddress });
let safeMessage = kit.createMessage(message);
safeMessage = await kit.signMessage(safeMessage, SigningMethod.ETH_SIGN);
// Owner 2 signs (repeat for each owner until threshold is met)
kit = await Safe.init({ provider, signer: ownerPrivateKey2, safeAddress });
safeMessage = await kit.signMessage(safeMessage, SigningMethod.ETH_SIGN);
// Assemble combined signature bytes
const signature = buildSignatureBytes(
Array.from(safeMessage.signatures.values())
);
// Monerium calls isValidSignature(messageHash, signature) on the contract
const response = await fetch('https://api.monerium.dev/addresses', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
profile: profileId,
address: safeAddress,
chain: 'ethereum',
message,
signature,
}),
});
// 201 — address linked
Execute a signMessage transaction that records the message hash on-chain in the contract. Submit "0x" as the signature — Monerium will poll isValidSignature(messageHash, '0x') until the transaction is confirmed (up to 5 days).
import Safe, {
hashSafeMessage,
getSignMessageLibContract,
SigningMethod,
} from '@safe-global/protocol-kit';
import { OperationType } from '@safe-global/safe-core-sdk-types';
const message = 'I hereby declare that I am the address owner.';
const hash = hashSafeMessage(message);
// Build the signMessage transaction
let kit = await Safe.init({ provider, signer: ownerPrivateKey1, safeAddress });
const signMessageLib = await getSignMessageLibContract({
safeProvider: kit.getSafeProvider(),
safeVersion: '1.3.0',
});
let tx = await kit.createTransaction({
transactions: [
{
to: await signMessageLib.getAddress(),
value: '0',
data: signMessageLib.encode('signMessage', [hash]),
operation: OperationType.DelegateCall,
},
],
});
tx = await kit.signTransaction(tx, SigningMethod.ETH_SIGN);
// Collect remaining owner signatures and execute
kit = await Safe.init({ provider, signer: ownerPrivateKey2, safeAddress });
tx = await kit.signTransaction(tx, SigningMethod.ETH_SIGN);
await kit.executeTransaction(tx);
// Submit '0x' — Monerium polls isValidSignature(messageHash, '0x') asynchronously
const response = await fetch('https://api.monerium.dev/addresses', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
profile: profileId,
address: safeAddress,
chain: 'ethereum',
message,
signature: '0x',
}),
});
// 202 — Monerium will verify the on-chain signature and link the address
Wallet types
How you produce the signature depends on your wallet type:
| Wallet type | How ownership is proven | Result |
|---|---|---|
| EOA (MetaMask, Ledger, etc.) | Private key signs the message directly | 65-byte signature |
| Smart contract wallet — off-chain | Owners sign off-chain; Monerium verifies via EIP-1271 | Combined signature bytes |
| Smart contract wallet — on-chain | signMessage transaction recorded on-chain; Monerium polls EIP-1271 | "0x" — returns 202 |
EIP-1271
EIP-1271 is the standard for signature validation in smart contract wallets. Because smart contract wallets (Safe, Argent, and others) have no private key of their own, they implement an isValidSignature(bytes32 messageHash, bytes signature) method. Monerium calls this method on the contract instead of recovering a signer from the signature bytes. The contract applies its own authorization logic — for example, a multisig threshold — and returns 0x1626ba7e if the signature is considered valid.
- Off-chain EIP-1271: The wallet owners collect their signatures externally, assemble the combined signature bytes, and submit them. Monerium calls
isValidSignature(messageHash, signature)immediately and links the address on success (201). - On-chain EIP-1271: A
signMessagetransaction is broadcast to the blockchain, recording the message hash as approved by the contract. Your app submits"0x"as the signature. Monerium callsisValidSignature(messageHash, '0x')and continues polling for up to 5 days until the on-chain transaction is confirmed (202).
EUR IBAN
Once a profile is approved and a wallet is linked, provision a dedicated IBAN. All incoming EUR payments to that IBAN are automatically minted as EURe to the linked wallet.
Payments are processed over SEPA. When the counterpart bank supports SEPA Instant Credit Transfer (SCT Inst), funds typically arrive within seconds, 24/7. If the counterpart bank does not support SCT Inst, it falls back to SEPA Credit Transfer (SCT), which settles within 1 business day.
Request IBAN
Returns 202. Provisioning is asynchronous and typically completes within a few seconds.
await fetch('https://api.monerium.dev/ibans', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
address: '0x59cFC408d310697f9D3598e1BE75B0157a072407',
chain: 'ethereum', // or 'gnosis', 'polygon'
}),
});
// 202: IBAN provisioning started
// 304: profile already has an IBAN, use PATCH /ibans/{iban} to move it
If the profile already has an IBAN, the API returns 304. Use PATCH /ibans/{iban} to move the existing IBAN to a different address or chain.
Retrieve the IBAN
Before requesting an IBAN, verify the profile is approved via GET /profiles/:profileId; the state must be "approved". Once you call POST /ibans, provisioning typically completes within seconds. Fetch the result on demand with GET /ibans:
const response = await fetch('https://api.monerium.dev/ibans', {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/vnd.monerium.api-v2+json',
},
});
const ibans = await response.json();
const iban = ibans.find(i => i.state === 'active');
// Display iban.iban to the customer once active
If the IBAN is not yet active, wait for the user to trigger a refresh rather than repeatedly fetching; the API enforces rate limits. For real-time activation events, the Whitelabel plan provides iban.updated webhooks.
Move an IBAN
Re-associate an existing IBAN with a different address or chain. Incoming payments will be routed to the new address from that point forward.
await fetch(`https://api.monerium.dev/ibans/${iban}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
address: '0x59cFC408d310697f9D3598e1BE75B0157a072407',
chain: 'gnosis',
}),
});
// 200: IBAN moved
Orders
Orders represent fund movements. There are two kinds:
- Issue: created automatically by Monerium when funds arrive at a customer's IBAN or wallet. No action required from your app.
- Redeem: initiated by your app. EURe is burned from the linked wallet and the funds are sent to the counterpart, either via SEPA to a bank account, or cross-chain to another wallet.
Both SEPA and cross-chain redeem orders use the same POST /orders endpoint. The difference is in the counterpart field.
Incoming payments
Monerium creates an issue order automatically when a SEPA payment arrives at the customer's IBAN or when EURe is received cross-chain. Your app does not need to initiate anything.
Routing with memo
By default, EURe is minted to the wallet and chain the IBAN is linked to. To route an incoming payment to a different linked wallet or chain, the sender includes a routing instruction in the SEPA payment memo using the format <chain>:<address>:
gnosis:0x59cFC408d310697f9D3598e1BE75B0157a072407
If the address is linked to the profile on that chain, Monerium mints to that destination instead of the default.
Signing an order
Every redeem order requires a cryptographic signature from the wallet holding the EURe being redeemed. The message is unique per order and must be signed before submission.
The message format depends on the order type:
SEPA:
Send <CURRENCY> <AMOUNT> to <IBAN> at <TIMESTAMP>
Example: Send EUR 100.00 to EE52...1285 at 2024-07-12T12:02Z
Cross-chain:
Send <CURRENCY> <AMOUNT> to <ADDRESS> on <CHAIN> at <TIMESTAMP>
Example: Send EUR 100.00 to 0x4B4c...7760 on gnosis at 2024-07-12T12:02Z
Timestamp rules: RFC3339 format, accurate to the minute (no seconds). Must be within 5 minutes of the current time, or any time in the future. Set it 1–2 minutes ahead to account for latency. The IBAN may be full (no spaces) or in shortened format (EE12...2602).
For EOA wallets, sign the message with eth_sign / personal_sign as shown in the examples below. For smart contract wallets (EIP-1271), the same off-chain and on-chain approaches apply; see the Link wallet section for the full signing walkthrough.
SEPA payment
EURe is burned from the wallet and a SEPA transfer is sent from the customer's IBAN to the specified bank account. Set counterpart.identifier.standard to "iban" and provide recipient details.
counterpart.details can be an individual (firstName, lastName, country) or a company (companyName, country).
memo and referenceNumber are optional. If neither is provided, Monerium sets the memo to "Powered by Monerium" before sending the payment. If both are provided, referenceNumber takes precedence and memo is ignored.
| Field | SEPA field | Format | Purpose |
|---|---|---|---|
memo | Unstructured remittance information | Free text, max 140 chars | Human-readable note visible on the recipient's bank statement. Use for payment descriptions, invoice notes, or any context for the recipient. |
referenceNumber | RF Creditor Reference (ISO 11649) | Max 35 chars, typically RF + 2 check digits + reference | Machine-readable structured reference for automated payment reconciliation. Use when the recipient's system needs to match the payment to an invoice or order automatically. |
Use memo when you want a readable label on the bank statement. Use referenceNumber when the recipient processes payments programmatically and needs a structured reference.
import { ethers } from 'ethers';
const amount = '100.00';
const iban = 'EE521273842688571285'; // recipient IBAN, no spaces
const timestamp =
new Date(Date.now() + 2 * 60_000).toISOString().slice(0, 16) + 'Z'; // 2 min buffer // "2024-07-12T12:02Z"
const message = `Send EUR ${amount} to ${iban} at ${timestamp}`;
const wallet = new ethers.Wallet(privateKey);
const signature = await wallet.signMessage(message);
const response = await fetch('https://api.monerium.dev/orders', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
address: '0x59cFC408d310697f9D3598e1BE75B0157a072407', // wallet holding EURe
currency: 'eur',
chain: 'ethereum',
kind: 'redeem',
amount,
counterpart: {
identifier: { standard: 'iban', iban },
details: { firstName: 'Jane', lastName: 'Doe', country: 'EE' },
// Corporate recipient: { companyName: 'Acme Ltd', country: 'EE' }
},
message,
signature,
memo: 'Payment for invoice #1234', // optional, appears on recipient's bank statement
referenceNumber: 'RF18539007547034', // optional, structured RF reference for automated reconciliation
// memo and referenceNumber are are mutually exclusive, if both are present, referenceNumber takes precedence and memo is ignored
}),
});
// 200: order placed and processing
Orders with an amount of €15,000 or more require a supporting document (invoice or contract). Upload it first via POST /files to obtain a fileId, then include supportingDocumentId: fileId in the order payload.
Cross-chain transfer (bridging)
Cross-chain transfers are always initiated by the partner. Set counterpart.identifier.standard to "chain" and specify the recipient address and destination chain. No counterpart.details are required; the recipient is identified by their blockchain address.
Monerium processes the transfer by creating two orders internally: a redeem on the source chain (the one you submit) and a dependent issue on the destination chain. The redeem does not finalize unless the destination issue succeeds. Monitor only the redeem order: it contains all the information you need, including the final state and meta.txHashes.
import { ethers } from 'ethers';
const amount = '100.00';
const recipientAddress = '0x4B4c34f35b0Bb9Af56418FAdD4677ce45ADF7760';
const destinationChain = 'gnosis';
const timestamp =
new Date(Date.now() + 2 * 60_000).toISOString().slice(0, 16) + 'Z'; // 2 min buffer
const message = `Send EUR ${amount} to ${recipientAddress} on ${destinationChain} at ${timestamp}`;
const wallet = new ethers.Wallet(privateKey);
const signature = await wallet.signMessage(message);
const response = await fetch('https://api.monerium.dev/orders', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
address: '0x59cFC408d310697f9D3598e1BE75B0157a072407', // source wallet on ethereum
currency: 'eur',
chain: 'ethereum',
kind: 'redeem',
amount,
counterpart: {
identifier: {
standard: 'chain',
address: recipientAddress,
chain: destinationChain,
},
},
message,
signature,
}),
});
// 200: redeem order placed; Monerium creates the destination issue order and coordinates both
Monitor orders
Use on-chain events as your trigger, do not poll GET /orders. Listen for Transfer events on the EURe token contract: a mint (from === address(0)) signals an incoming payment; a burn (to === address(0)) signals a processed redeem. When a relevant Transfer fires, fetch the order once by transaction hash:
// Using ethers.js, filter Transfer events on the EURe contract
const filter = eureContract.filters.Transfer();
eureContract.on(filter, async (from, to, value, event) => {
const isYourWallet = from === walletAddress || to === walletAddress;
const isMintOrBurn =
from === ethers.ZeroAddress || to === ethers.ZeroAddress;
if (!isYourWallet || !isMintOrBurn) return;
const txHash = event.log.transactionHash;
const response = await fetch(
`https://api.monerium.dev/orders?txHash=${txHash}`,
{
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/vnd.monerium.api-v2+json',
},
}
);
const orders = await response.json();
const order = orders[0];
// order.kind: 'issue' | 'redeem'
// order.state: 'pending' | 'processed' | 'rejected'
});
For real-time order events, the Whitelabel plan provides order.created and order.updated webhooks.