Whitelabel
Embed regulated euro accounts and SEPA payments in your product under your own brand. Each customer gets a dedicated IBAN and on-chain address; your users interact only with your app, never with Monerium.
Create an application
- Go to sandbox.monerium.dev and select Developers in the sidebar.
- Click Create application and select Whitelabel.
- Enter your application name and confirm.
- Copy the CLIENT ID and CLIENT SECRET from the credentials screen; you'll need both for the client credentials flow below.
When you're ready for production, repeat this at monerium.app. See Going live for the full checklist.
Integration flow
- Authenticate: Obtain an access token using Client Credentials.
- Set up webhooks: Register your endpoint to receive events for profile approvals, IBAN provisioning, and payments.
- Onboard the customer: Create a profile and submit identity data via KYC Sharing or KYC Reliance. Monitor approval via
profile.updatedwebhooks. - Link a wallet: Associate a blockchain address with the profile using a cryptographic proof of ownership.
- Issue an IBAN: Provision a dedicated IBAN once the profile is approved and a wallet is linked.
- Send and receive payments: Place redeem orders for outgoing SEPA payments and cross-chain transfers. Incoming payments are handled automatically.
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
Most steps in the Whitelabel flow are asynchronous — profile approvals, IBAN provisioning, payment processing. Webhooks let your backend react to these events without polling.
- Generate a secret and register your endpoint. Create a random 24–64 byte value, base64-encode it, and prefix it with
whsec_. Then post it along with your URL to:
- Node.js
- Go
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: [
'profile.updated',
'iban.updated',
'order.created',
'order.updated',
],
}),
});
// Store `secret` — you need it to verify incoming requests
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
)
raw := make([]byte, 32)
rand.Read(raw)
secret := "whsec_" + base64.StdEncoding.EncodeToString(raw)
body, _ := json.Marshal(map[string]any{
"url": "https://your-app.com/webhooks/monerium",
"secret": secret,
"types": []string{"profile.updated", "iban.updated", "order.created", "order.updated"},
})
req, _ := http.NewRequest("POST", "https://api.monerium.dev/webhooks", bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
// Store `secret` — you need it to verify incoming requests
-
Confirm the subscription — Monerium sends a
subscription.createdevent to your URL immediately after registration. Respond with200 OKto activate it. -
Handle incoming events. Verify the signature, return
200 OKimmediately, then process the event asynchronously. Each request includes awebhook-signatureheader with an HMAC-SHA256 signature over thewebhook-id,webhook-timestamp, and raw request body:
- Node.js
- Go
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; // already handled, skip
}
processEvent(JSON.parse(req.body)); // handle asynchronously
}
);
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
id := r.Header.Get("webhook-id")
timestamp := r.Header.Get("webhook-timestamp")
signature := r.Header.Get("webhook-signature")
key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
signed := fmt.Sprintf("%s.%s.%s", id, timestamp, body)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(signed))
expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
if signature != expected {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
if hasProcessed(id) {
return // already handled, skip
}
go processEvent(body) // handle asynchronously
}
Event types
| Event | When it fires |
|---|---|
profile.updated | Profile details or state changed |
iban.updated | IBAN details or state changed |
order.created | New order placed |
order.updated | Order state changed |
Retries
If your endpoint does not return 200 OK, Monerium retries up to 10 times over 12 hours using exponential backoff. Retried events carry the same webhook-id as the original — store processed IDs to avoid handling the same event twice.
Onboarding
Monerium supports two strategies for identity verification:
- KYC Sharing — you use SumSub for identity verification and share the verified data directly with Monerium using the applicant token. Available for personal profiles only.
- KYC Reliance — you hold the appropriate licences and have a KYC Reliance agreement with Monerium. You submit the verified identity data directly via the API. Required for corporate profiles and for partners without SumSub.
The steps below apply to both strategies. Where the paths diverge, tabs indicate which applies.
All requests act on your application's own profile by default. Include the customer's profile UUID to act on their behalf.
1. Create a profile
const response = await fetch('https://api.monerium.dev/profiles', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({ kind: 'personal' }), // or 'corporate'
});
const { id: profileId, state } = await response.json();
// Store profileId — required for all subsequent requests
2. Submit customer details
KYC Sharing is available for personal profiles only. Corporate profiles must always use KYC Reliance.
- KYC Sharing — Personal
- KYC Reliance — Personal
- KYC Reliance — Corporate
Transfer the verified applicant data from your SumSub account to Monerium using the applicant token. Returns 202; Monerium fetches the data asynchronously.
Before sharing, ensure your customer passes all required checks. See the KYC Guide — Individuals.
await fetch(`https://api.monerium.dev/profiles/${profileId}/share`, {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
provider: 'sumsub',
personal: { token: sumsubApplicantToken },
}),
});
// Returns 202 — monitor progress via profile.updated webhooks
Submit the customer's verified identity data directly. All fields are required.
Before submitting, ensure your customer passes all required checks. See the KYC Guide — Individuals.
await fetch(`https://api.monerium.dev/profiles/${profileId}/details`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
personal: {
firstName: 'Jane',
lastName: 'Doe',
address: 'Pennylane 123',
postalCode: '7890',
city: 'Liverpool',
country: 'GB',
nationality: 'GB',
birthday: '1990-05-15',
},
}),
});
Submit the company's verified details. Include all representatives (authorised signatories), final beneficiaries (25%+ ownership), and directors.
Before submitting, ensure the company passes all required checks. See the KYB Guide — Corporates.
await fetch(`https://api.monerium.dev/profiles/${profileId}/details`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
corporate: {
name: 'Acme Ltd',
registrationNumber: '123456',
registrationDate: '2020-01-15',
vatNumber: 'GB123456789',
website: 'https://acme.com',
address: 'Pennylane 123',
postalCode: '7890',
city: 'London',
country: 'GB',
countryState: 'England',
representatives: [
{
firstName: 'Jane',
lastName: 'Doe',
address: 'Pennylane 123',
postalCode: '7890',
city: 'London',
country: 'GB',
nationality: 'GB',
birthday: '1990-05-15',
idDocument: { number: 'A1234567', kind: 'passport' },
},
],
finalBeneficiaries: [
{
firstName: 'Jane',
lastName: 'Doe',
address: 'Pennylane 123',
postalCode: '7890',
city: 'London',
country: 'GB',
nationality: 'GB',
birthday: '1990-05-15',
ownershipPercentage: '100',
},
],
directors: [
{
firstName: 'Jane',
lastName: 'Doe',
address: 'Pennylane 123',
postalCode: '7890',
city: 'London',
country: 'GB',
nationality: 'GB',
birthday: '1990-05-15',
},
],
},
}),
});
3. Submit form data
Required for all integrations. Captures additional risk assessment data such as occupation, purpose of account, source of funds, and expected monthly volume.
- Personal
- Corporate
await fetch(`https://api.monerium.dev/profiles/${profileId}/form`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
personal: {
occupation: 'OCCUPATION_EMPLOYED',
profession: 'PROF_FINANCIAL_SERVICES_OTHER',
fundOrigin: 'FUND_ORIGIN_SALARY',
annualIncome: 'INCOME_50K_100K',
monthlyTurnover: 'TURNOVER_1K_10K',
monthlyTransactionCount: 'TX_COUNT_1_10',
activities: ['ACTIVITY_INVESTING_CRYPTO'],
publicFunction: false,
fundOwner: true,
usCitizen: false,
usTaxPerson: false,
taxId: '123-45-6789',
},
}),
});
await fetch(`https://api.monerium.dev/profiles/${profileId}/form`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
corporate: {
service: 'LEGAL_FORM_PRIVATE_LIMITED',
purpose: 'PURPOSE_COLLECTING_PAYMENTS',
activity: 'ACTIVITY_FINANCIAL_INSTITUTION',
fundOrigin: 'FUND_ORIGIN_REVENUE_OPERATIONS',
monthlyTurnover: 'TURNOVER_100K_TO_250K',
monthlyTransactionCount: 'TRANSACTION_COUNT_UNDER_100',
},
}),
});
4. Upload verifications
Upload documents via POST /files first to obtain a fileId, then submit them here.
- Personal — KYC Sharing
- Personal — KYC Reliance
- Corporate — KYC Reliance
idDocument, facialSimilarity, and proofOfResidency are populated automatically from SumSub. You only need this step if source of funds is required — either identified upfront (restricted residency, restricted nationality, or PEP) or triggered by a profile.updated webhook with sourceOfFunds: incomplete.
const { id: fileId } = await uploadFile(sofDocument); // POST /files
await fetch(`https://api.monerium.dev/profiles/${profileId}/verifications`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
personal: [{ kind: 'sourceOfFunds', fileId }],
}),
});
Submit the full set: idDocument, facialSimilarity, proofOfResidency, and sourceOfFunds if required.
// Upload each document via POST /files to get fileIds, then:
await fetch(`https://api.monerium.dev/profiles/${profileId}/verifications`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
personal: [
{ kind: 'idDocument', fileId: idDocumentFileId },
{ kind: 'facialSimilarity', fileId: selfieFileId },
{ kind: 'proofOfResidency', fileId: proofOfResidencyFileId },
// { kind: 'sourceOfFunds', fileId: sofFileId }, // if required
],
}),
});
Submit corporate verification documents. sourceOfFunds is included if required.
// Upload each document via POST /files to get fileIds, then:
await fetch(`https://api.monerium.dev/profiles/${profileId}/verifications`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.monerium.api-v2+json',
},
body: JSON.stringify({
corporate: [
{ kind: 'corporateName', fileId: corporateNameFileId },
{ kind: 'corporateAddress', fileId: corporateAddressFileId },
{ kind: 'registrationNumber', fileId: registrationNumberFileId },
{ kind: 'dateOfRegistration', fileId: dateOfRegistrationFileId },
{ kind: 'beneficialOwnership', fileId: beneficialOwnershipFileId },
{ kind: 'powerOfAttorney', fileId: powerOfAttorneyFileId },
// { kind: 'sourceOfFunds', fileId: sofFileId }, // if required
],
}),
});
5. Monitor approval
Listen for profile.updated webhooks. Check the top-level state and the verifications array:
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.meta.type !== 'profile.updated') return;
const { state, verifications } = event.data;
if (state === 'approved') {
// Profile is approved — proceed to provision an IBAN
}
if (state === 'incomplete') {
const sofVerification = verifications?.find(v => v.kind === 'sourceOfFunds');
if (sofVerification?.state === 'incomplete') {
// Source of funds required — collect and submit via PATCH /verifications
}
}
});
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
Listen for iban.updated webhooks. The data field contains the full IBAN object including the assigned IBAN number.
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 !== 'iban.updated') return;
const { iban, address, chain, profile } = event.data;
// Store the IBAN and display it to the customer
}
);
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
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
}