Fund a wallet: Monierate
How Monierate takes wallet top-ups in crypto through Volume, from keys to going live, with Node.js you can copy. Any balance top-up works the same way.
Monierate users keep a dollar wallet on their account and spend it on Pro plans and API requests. To top it up with crypto, a user picks an amount, pays it on Volume's checkout from their exchange or wallet, and the wallet is credited once the payment is final.
This guide builds that flow end to end. If your product has a balance of any kind (credits, a wallet, prepaid usage) the integration is the same: only the names change.
The server code is Node.js 20 or later with Express. The parts that touch money are real files that this site's tests run, so what you copy is what was tested.
How it fits together
- A user asks to add $25.00 on Monierate.
- Monierate's server creates a $25.00 invoice and a checkout for it, and records which user the checkout is for.
- It sends the user to the checkout,
paywithvolume.com/cs_…. - The user pays. Once the payment is final, Volume sends
invoice.paidto Monierate's webhook endpoint. - Monierate credits the user's wallet $25.00, once.
- The user lands back on Monierate's wallet page, which shows the new balance.
The wallet is credited by the webhook, never by the user arriving back. Anyone can open a URL; only Volume can sign an event.
Get a key and a webhook secret
Sign up in the dashboard. Under Developers:
- make an API key named for your server, with Test chosen, and copy its
vk_test_…; - add your webhook endpoint's URL, such as
https://api.monierate.com/webhooks/volume, and copy itswhsec_…signing secret. While developing on a laptop, register a tunnel's address instead: see webhooks.
Both are shown once. Put them in your server's environment:
VOLUME_API_KEY=vk_test_...
VOLUME_WEBHOOK_SECRET=whsec_...Each environment of yours gets one pair, in one mode: staging the test pair, production the live pair once your account is live. Nothing else changes between them.
Start a top-up
A small client for the API:
const VOLUME_API = 'https://api.paywithvolume.com';
/**
* Calls the Volume API with your key. A refusal throws, carrying its `code`.
* @param {string} path
* @param {{ method?: string, body?: object, idempotencyKey?: string }} [options]
*/
export async function volume(path, { method = 'GET', body, idempotencyKey } = {}) {
const response = await fetch(`${VOLUME_API}${path}`, {
method,
headers: {
authorization: `Bearer ${process.env.VOLUME_API_KEY}`,
...(body ? { 'content-type': 'application/json' } : {}),
...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await response.json();
if (!response.ok) {
const error = new Error(json.error?.message ?? `Volume answered ${response.status}`);
error.code = json.error?.code;
error.requestId = json.error?.request_id;
throw error;
}
return json;
}Starting a top-up is two calls: an invoice for the amount, then a checkout for the invoice.
/**
* Opens a checkout for one top-up and returns where to send the user. `fundingId` is
* your own record of the top-up: retrying with the same one never opens a second
* invoice, because it makes the idempotency keys.
*/
export async function startFunding({ fundingId, email, amount }) {
const invoice = await volume('/v1/invoices', {
method: 'POST',
idempotencyKey: `${fundingId}:invoice`,
body: {
amount, // a string such as "25.00", never a float
description: 'Monierate wallet top-up',
customer_email: email,
metadata: { funding_id: fundingId },
},
});
const checkout = await volume('/v1/checkout_sessions', {
method: 'POST',
idempotencyKey: `${fundingId}:checkout`,
body: {
invoice_id: invoice.id,
success_url: `https://monierate.com/wallet?funding=${fundingId}`,
cancel_url: 'https://monierate.com/wallet',
},
});
return { invoiceId: invoice.id, checkoutSessionId: checkout.id, url: checkout.url };
}amountis dollars as a string with at most two decimals, such as"25.00". Check it against your own minimum first; Volume takes anything from $0.01.customer_emailputs the user's email on the invoice. The checkout shows it, and in live mode Volume emails the receipt there.metadatais your own reference, read back withGET /v1/invoices/{id}. Events do not carry it, so keep the ids that Volume returns as well.- The idempotency keys come from
fundingId, so a retried request lands on the same invoice and checkout instead of opening new ones. See idempotency.
Then the route your wallet page calls. It records the checkout before the user can pay it, because crediting looks the checkout up:
import { randomUUID } from 'node:crypto';
import express from 'express';
import { startFunding } from './volume.js';
import { recordCheckout } from './wallet.js';
const app = express();
app.post('/wallet/fund', express.json(), async (req, res) => {
const user = req.user; // however your app knows who is signed in
const amount = String(req.body.amount); // after checking it against your minimum
const fundingId = randomUUID(); // better: the id of a top-up record you saved first
const { url, invoiceId, checkoutSessionId } = await startFunding({
fundingId,
email: user.email,
amount,
});
await recordCheckout({ checkoutSessionId, invoiceId, userId: user.id });
res.json({ url }); // the browser goes there next
});Take the webhook
Volume POSTs events to your endpoint. The handler checks the signature, then credits on the three events that say money has arrived:
import { owedCents } from './owed.js';
import { verifySignature } from './signature.js';
const CREDITING = new Set(['invoice.paid', 'invoice.overpaid', 'invoice.underpaid']);
/**
* Handles one delivery and returns the HTTP status to answer with. `creditUpTo` is
* yours: see "Credit the wallet once" below.
*/
export async function handleVolumeEvent({ rawBody, signature, secret, creditUpTo }) {
if (!verifySignature(secret, rawBody, signature)) return 400;
const event = JSON.parse(rawBody);
if (CREDITING.has(event.type)) {
const checkout = event.data.checkout_session;
await creditUpTo(checkout.id, owedCents(checkout));
}
// Anything else (payment.confirmed, invoice.expired, a test event) needs nothing
// here. Answer 2xx anyway, or Volume keeps retrying it.
return 200;
}verifySignature is in webhooks; save it
as signature.js beside this file.
Wire it to a route that reads the raw body, since the signature covers the exact
bytes Volume sent. Register it before any express.json() that applies to every route:
import { handleVolumeEvent } from './webhook.js';
import { creditUpTo } from './wallet.js';
app.post('/webhooks/volume', express.raw({ type: 'application/json' }), async (req, res) => {
const status = await handleVolumeEvent({
rawBody: req.body.toString('utf8'),
signature: req.get('Volume-Signature'),
secret: process.env.VOLUME_WEBHOOK_SECRET,
creditUpTo,
});
res.sendStatus(status);
});Credit the wallet once
Most top-ups are one event, invoice.paid, for exactly the invoice. But payers withdraw
from exchanges, and they get amounts wrong, so the handler credits what actually
arrived:
| The user sent | Events | Wallet credited |
|---|---|---|
| Exactly $25.00 | invoice.paid | $25.00 |
| $24.90, inside the tolerance | invoice.paid | $25.00: Volume covers the 10 cents |
| $22.50, then $2.50 more | invoice.underpaid, then invoice.paid | $22.50, then $2.50 more |
| $22.50, and stopped | invoice.underpaid | $22.50 |
| $27.50 | invoice.paid, invoice.overpaid | $27.50 |
| $25.00, twice | invoice.paid, invoice.overpaid | $50.00 |
So the rule is: a checkout owes the wallet everything that has arrived, at the rate the user was quoted, and never less than the invoice once it is paid. Every crediting event carries the checkout as it stood, which is all it takes to work that out:
/** A decimal string as an integer and its number of decimal places: "0.25" is 25n, 2. */
function decimal(text) {
const match = /^(\d+)(?:\.(\d+))?$/.exec(text);
if (!match) throw new Error(`Not a decimal: ${text}`);
const fraction = match[2] ?? '';
return [BigInt(match[1] + fraction), fraction.length];
}
/** `amount` coins at `rate` dollars per coin, in whole cents, rounded down. */
function cents(amount, rate) {
const [units, places] = decimal(amount);
const [price, rateScale] = decimal(rate);
return (units * price * 100n) / 10n ** BigInt(places + rateScale);
}
const PAID = new Set(['paid', 'overpaid', 'settled']);
/**
* What a checkout owes the wallet, in cents, from the `checkout_session` an event
* carries: everything that has arrived, at the rate the payer was quoted, and never
* less than the invoice once it is paid (Volume covers a small shortfall).
*/
export function owedCents(checkout) {
const received = checkout.amount_received ? cents(checkout.amount_received, checkout.rate) : 0n;
if (!PAID.has(checkout.status)) return received;
const price = cents(checkout.amount_usd, '1');
return received > price ? received : price;
}It works in whole cents with BigInt, never floating point. A Bitcoin payment is
valued at the rate the user was quoted, which is the rate Volume credits you at.
Now keep, for each checkout, how much you have credited for it, and raise that to the new total in one transaction, crediting the wallet the difference. A repeated event then credits nothing, a late one cannot take anything back, and a top-up credits only the rest. Here it is for Postgres:
CREATE TABLE volume_checkouts (
checkout_session_id text PRIMARY KEY,
invoice_id text NOT NULL,
user_id text NOT NULL,
credited_cents bigint NOT NULL DEFAULT 0
);import pg from 'pg';
const db = new pg.Pool(); // reads the PG* environment variables
export async function recordCheckout({ checkoutSessionId, invoiceId, userId }) {
await db.query(
`INSERT INTO volume_checkouts (checkout_session_id, invoice_id, user_id)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[checkoutSessionId, invoiceId, userId],
);
}
/** Raises what a checkout has credited to `owed` cents, crediting the difference. */
export async function creditUpTo(checkoutSessionId, owed) {
const client = await db.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`SELECT user_id, credited_cents FROM volume_checkouts
WHERE checkout_session_id = $1 FOR UPDATE`,
[checkoutSessionId],
);
const row = rows[0];
if (row && owed > BigInt(row.credited_cents)) {
await client.query(
'UPDATE volume_checkouts SET credited_cents = $2 WHERE checkout_session_id = $1',
[checkoutSessionId, owed.toString()],
);
// Your wallet's own credit, in this same transaction:
await creditWallet(client, row.user_id, owed - BigInt(row.credited_cents));
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}Any database works the same way, as long as the new total and the wallet credit are
written together or not at all. The FOR UPDATE lock makes two events for the same
checkout take turns, so two deliveries at once cannot both credit the same cents.
Volume's fee comes out of what Volume pays you, not out of what the user sent. The user above is credited the full $25.00, and Volume credits your balance $24.75 at a 1% fee. Whether to pass the fee on is your decision.
Bring the user back
After paying, the user's receipt has a button back to your success_url. By then the
webhook has usually arrived, but not always: show the wallet as it stands, with a line
such as "Confirming your payment" while their top-up is still uncredited, and let the
page refresh itself from your own server. Never credit from this page, and never call
Volume from the browser.
cancel_url is where Cancel payment takes them. Nothing was paid; leave the top-up
as it is.
Test it
With the test pair in your staging environment, start a top-up from your app and open the checkout. Pick Binance, then Tether USD, and use the page's Test mode panel:
- Send the exact amount. The wallet goes up by the amount, once.
- Send too little, then Send the exact amount on the same checkout. The wallet goes up by what arrived, then by the rest.
- Send too much on a new top-up. The wallet goes up by all of it.
- Send the exact amount twice. The wallet goes up by both.
- Post a made-up body to your endpoint with any
Volume-Signature. It answers400and credits nothing. - Stop your server, pay a test checkout, and start it again a few minutes later. Volume's retry arrives and the wallet is credited once.
Every step's deliveries, and your server's answers, are under Developers in the dashboard. Test mode has the rest.
Go live
- Wait for activation. Volume reviews your business after sign-up. The dashboard
says when live payments are open, and
GET /v1/accountshowslive_access: true. - Set up an authenticator under Settings. A live key asks for a code from it.
- Make the live pair. Make a key with Live chosen, then switch the dashboard to live and add your production endpoint, which gets its own live secret.
- Deploy them to production as
VOLUME_API_KEYandVOLUME_WEBHOOK_SECRET. Livesuccess_urlandcancel_urlmust behttps. - Add where you get paid: your bank account, under Payouts.
- Make a small real top-up yourself, and watch it reach the wallet.
Going live has each step in full.
Checklist
- Keys and secrets live only in your server's environment.
- The webhook route checks
Volume-Signatureon the raw body, and answers400when it does not match. - Wallets are credited only from
invoice.paid,invoice.underpaidandinvoice.overpaid, up to a total per checkout, never fromsuccess_url. - Amounts stay strings or integers of cents. Nothing about money is a float.
- Idempotency keys come from your own records.
- You have tried exact, short and topped up, over, twice, a forged event and a server that was down.