Private
The Private plan gives you direct API access to your own Monerium account. There are no external customers; the application acts on your own wallet, IBAN, and orders.
Create an application
- Go to sandbox.monerium.dev and select Developers in the sidebar.
- Click Create application and select Private.
- Enter your application name and confirm.
- Copy the CLIENT ID and CLIENT SECRET from the credentials screen.
When you're ready for production, see Going live below.
Integration flow
- Authenticate: Obtain an access token using Client Credentials.
- Set up webhooks: Register your endpoint to receive order events.
- Link a wallet: Associate a blockchain address with your account.
- Set up an IBAN: Provision your IBAN via the dashboard or API.
- Send and receive payments: Place redeem orders for outgoing SEPA payments and cross-chain transfers.
Authentication
Use the CLIENT ID and CLIENT SECRET from your application credentials to request an access token.
Request tokens by posting your credentials to:
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: 'client_credentials',
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET',
}),
});
const { access_token, expires_in } = await response.json();
// {
// access_token: "EoWmpc2uSZar6h2bKgh",
// expires_in: 3600,
// token_type: "Bearer"
// }
Store the access token and include it as Authorization: Bearer <token> on every API request. It expires after 1 hour; when it does, repeat this request to get a new one.
Your backend should handle the token exchange and make all Monerium API calls. Never expose your client_secret or access token to frontend code.
Webhooks
Order processing is asynchronous. Register a webhook endpoint to receive order.created and order.updated events. Profile approval is notified by email, not webhook.
Register your endpoint
Generate a secret, then register your URL:
import crypto from 'crypto';
const secret = 'whsec_' + crypto.randomBytes(32).toString('base64');
const response = await fetch('https://api.monerium.dev/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://your-app.com/webhooks/monerium',
secret,
types: ['order.created', 'order.updated'],
}),
});
// Store secret; you need it to verify incoming requests
Monerium sends a subscription.created event to your URL immediately after registration. Respond with 200 OK to activate the subscription.
Verify signatures
Each request includes a webhook-signature header containing an HMAC-SHA256 signature over the webhook-id, webhook-timestamp, and raw request body. Verify it before processing:
import crypto from 'crypto';
app.post(
'/webhooks/monerium',
express.raw({ type: 'application/json' }),
async (req, res) => {
const id = req.headers['webhook-id'];
const timestamp = req.headers['webhook-timestamp'];
const signature = req.headers['webhook-signature'];
const signed = `${id}.${timestamp}.${req.body}`;
const expected = crypto
.createHmac('sha256', Buffer.from(SECRET.replace('whsec_', ''), 'base64'))
.update(signed)
.digest('base64');
if (signature !== `v1,${expected}`) {
return res.sendStatus(401);
}
res.sendStatus(200);
if (await hasProcessed(id)) return; // deduplicate retried events
processEvent(JSON.parse(req.body));
}
);
If your endpoint does not return 200 OK, Monerium retries up to 10 times over 12 hours using exponential backoff. Store processed webhook-id values to avoid handling the same event twice.
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
The simplest way to set up your IBAN is directly in the Monerium app dashboard. For programmatic provisioning, use the API:
Request IBAN
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
Move an IBAN
Re-associate your 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
The primary event to handle. Fired when an order changes state; act on processed and rejected.
app.post(
'/webhooks/monerium',
express.raw({ type: 'application/json' }),
async (req, res) => {
// ... verify signature (see Webhooks section)
res.sendStatus(200);
const event = JSON.parse(req.body);
if (event.type !== 'order.updated') return;
const { id, state, meta } = event.data;
if (state === 'processed') {
// Order complete, meta.txHashes contains the on-chain transaction hashes
console.log('tx hashes:', meta.txHashes);
}
if (state === 'rejected') {
// Order rejected, meta.rejectedReason explains why
console.error('rejected:', meta.rejectedReason);
}
}
);
To react the moment an incoming payment is detected, before it is processed, also subscribe to order.created.
const event = JSON.parse(req.body);
if (event.type !== 'order.created') return;
const { kind, amount, currency, chain, counterpart } = event.data;
if (kind === 'issue') {
// Incoming payment detected, notify the customer early
}
Going live
Private applications do not require a partner review.
- Complete KYC/KYB at monerium.app/profiles. Individuals complete KYC; businesses complete KYB.
- Create a production application at monerium.app following the same steps as sandbox.
- Start building. Once your profile is approved, your production credentials are active.