MaxMerit Exchange API
One API for fiat-to-crypto, crypto-to-fiat and crypto-to-crypto exchanges settled by a Toronto money services business, plus an embeddable widget, a hosted checkout and custody accounts for approved partners. The endpoints, field names and statuses mirror the Guardarian partner API, so an integration written for one works against the other. Access is by application: sandbox keys are issued once a partner is approved, live keys after onboarding.
Base URL
https://maxmeritexchange.com
Format
JSON, snake_case
Auth
x-api-key header
Quickstart
Get a quote, create a transaction, and send your customer to the hosted checkout. Three calls, no SDK required.
1. Get an estimate
curl "https://maxmeritexchange.com/api/v1/estimate?from_currency=CAD&to_currency=BTC&from_amount=300&payment_method=interac" \ -H "x-api-key: mm_test_sk_demo_0000000000000000000000000000"
The response is a flat object like Guardarian's: value is what the customer receives, estimated_exchange_rate the net rate, and id lets you lock the price for two minutes.
2. Create the transaction
curl -X POST https://maxmeritexchange.com/api/v1/transaction \
-H "x-api-key: mm_test_sk_demo_0000000000000000000000000000" \
-H "content-type: application/json" \
-d '{
"estimate_id": "est_8k3mq2p7w9ab",
"payout_address": "bc1q...",
"external_partner_link_id": "order_55",
"redirects_successful": "https://yourapp.com/thanks",
"customer": { "contact_info": { "email": "alex@example.com" }, "billing_info": { "country_alpha_2": "CA" } }
}'3. Redirect, then listen
Send the customer to redirect_url. They pay (or deposit crypto), and every status change is POSTed to your webhook URL. You can also poll GET /api/v1/transactions/:id.
const res = await fetch("https://maxmeritexchange.com/api/v1/transaction", {
method: "POST",
headers: { "x-api-key": process.env.MM_API_KEY, "content-type": "application/json" },
body: JSON.stringify({ from_currency: "CAD", to_currency: "ETH", from_amount: 500, deposit_type: "VISA_MC", payout_address: "0x..." }),
});
const tx = await res.json();
window.location.href = tx.redirect_url; // hosted checkoutAuthentication
Every partner has two keys, both shown in the admin dashboard when the partner is created.
| mm_live_sk_… | Secret key. Server to server only, sent as x-api-key (or Authorization: Bearer). Unlocks every endpoint, including transaction listing and custody. |
| mm_live_pk_… | Public widget key. Safe to embed in HTML. Used by the hosted widget and checkout (?partner=). Can quote, create, read and cancel its own transactions, nothing else. |
Test keys use the mm_test_ prefix. The seeded demo secret key for local development is mm_test_sk_demo_0000000000000000000000000000 and its widget key is mm_test_pk_demo.
Sandbox and live
Partners start in sandbox mode. Sandbox transactions are real API objects with fake money: a buy moves from waiting_for_deposit to exchanging, sending and finished on its own within about a minute, webhooks fire for every step, and custody balances update. Use PATCH /transactions/:id/checkout with {"simulate":"deposit"} to skip the wait. Switching a partner to live in the admin dashboard issues new live keys; sandbox keys keep working for testing.
Guardarian compatibility
MaxMerit runs its own rails and keeps Guardarian as a backup liquidity provider. To make that invisible to partners, the public API is shaped after Guardarian's: the same paths under /api/v1, the same snake_case field names, the same transaction statuses and the same webhook envelope. Point an existing Guardarian integration at https://maxmeritexchange.com/api/v1 with a MaxMerit key and it works; MaxMerit-only features are additive fields you can ignore.
| Guardarian | MaxMerit | Notes |
|---|---|---|
| GET /v1/status | GET /api/v1/status | Adds store, rates and custody status. |
| GET /v1/currencies | GET /api/v1/currencies | Same shape; /currencies/crypto and /currencies/fiat add prices and limits. |
| GET /v1/payment-categories | GET /api/v1/payment-categories | VISA_MC, SEPA, APPLE_PAY ... plus INTERAC, EFT, CASH. |
| GET /v1/market-info/min-max-range/{from_to} | GET /api/v1/market-info/min-max-range/{from_to} | Identical. |
| GET /v1/estimate | GET /api/v1/estimate | Same params; response adds id (price lock), fees and provider. |
| POST /v1/transaction | POST /api/v1/transaction | Same body; adds estimate_id, payout_type "custody", custody_customer_ref. |
| GET /v1/transaction/{id} | GET /api/v1/transaction/{id} | Same fields (payout_address, output_hash, external_partner_link_id ...). |
| Webhook { payload, version, requesterType } | Same envelope | Signed with X-MM-Signature; adds event, event_id, mode. |
| calculator/v2?default_side=buy_crypto&default_fiat_currency=EUR... | /widget?partner=...&default_side=buy_crypto&default_fiat_currency=CAD... | Guardarian calculator parameters are accepted as aliases. |
Routing
Settings → Routing chooses the rails: maxmerit (ours only), guardarian (everything upstream) or auto (ours whenever the pair is enabled here, Guardarian as fallback). A Guardarian-routed transaction carries provider: "guardarian" and provider_id (the upstream id), its redirect_url is Guardarian's checkout, and its status is mirrored from Guardarian on every read. Set GUARDARIAN_API_KEY to enable it; without a key, auto routing stays on MaxMerit rails.
Embeddable widget
The hosted widget is the same component that runs on our homepage. One script tag, no build step, resizes itself inside your page.
<div id="maxmerit-widget"></div>
<script src="https://maxmeritexchange.com/embed.js"></script>
<script>
MaxMeritWidget.mount({
container: "#maxmerit-widget",
partner: "mm_live_pk_yourkey", // public widget key
side: "buy", // buy | sell | swap
fiat: "CAD", crypto: "BTC", amount: 300,
theme: "light", // light | dark
accent: "gold", // gold | navy | blue | green
directions: "buy,sell", // optional: hide tabs
onFinished: (e) => console.log("done", e.transactionId),
});
</script>Prefer a plain link or your own iframe? Every option is a query parameter on /widget: /widget?partner=mm_live_pk_yourkey&side=buy&fiat=CAD&crypto=BTC&amount=300&theme=dark&accent=green. Try it live on the configurator.
Events
The iframe posts messages to the parent window: mm:ready, mm:resize (height), mm:checkout_started (estimateId) and mm:finished (transactionId, status). The embed script forwards them to your onEvent callback.
Hosted checkout
Transactions created through the API return a redirect_url on our domain. The checkout collects whatever you did not provide (wallet address or custody choice, email, country), shows payment or deposit instructions, and tracks status live. When the customer finishes we send them to your success_url if you set one. Everything the checkout does is available to you directly through PATCH /api/v1/transactions/:id/checkout if you prefer to build your own screens.
Transaction lifecycle
newCreatedwaiting_for_customerWaiting for customerwaiting_for_depositWaiting for depositon_holdOn hold (review)exchangingExchangingsendingSendingfinishedFinishedfailedFailedrefundedRefundedexpiredExpiredcancelledCancelledBuy: waiting_for_deposit → exchanging → sending → finished. Sell and swap are the same once the crypto deposit is detected. waiting_for_customer means the checkout still needs a wallet address. on_hold means an identity check or manual review is pending. expired is set automatically when nothing arrives within an hour of the quote.
API reference
Click an endpoint for parameters and examples. The machine-readable version is at /api/v1/openapi.
Catalog
Currencies, networks, payment methods and countries.
Pricing
Live rates, limits and binding estimates.
Transactions
Create exchanges, send customers to the hosted checkout and track status.
Custody
NakoPay custody accounts and withdrawals.
Webhooks
Signed notifications from MaxMerit to you, and from NakoPay to MaxMerit.
Webhooks
Set a webhook URL on your partner profile and we POST JSON for every event. Verify the signature before trusting the payload.
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-MM-Event: transaction.status_changed
X-MM-Delivery: whd_7hq2m9kd3pxz
X-MM-Signature: t=1760200000,v1=5f1c…e9a0
{
"id": "evt_…", "type": "transaction.status_changed", "created_at": "…", "mode": "live",
"data": { "transaction": { "id": "tx_…", "status": "finished", "external_id": "order_55", … }, "previous_status": "sending" }
}import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5 min tolerance
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Events: transaction.created, transaction.status_changed, transaction.finished, transaction.failed, custody.deposit_detected, custody.payout_sent. Respond with any 2xx within 10 seconds. Failed deliveries retry 5 times with exponential backoff and can be replayed from the admin dashboard.
Custody accounts
By default crypto is sent to the customer's own wallet. Partners approved for custody can pass payout_type: "custody" and the crypto is delivered into a custody account operated by the exchange's custody provider instead. Accounts are keyed by your custody_customer_ref (one per customer, asset and network), so a returning customer keeps accumulating in the same account. Balances, deposit addresses and withdrawals are available through the Custody endpoints, and the custody provider notifies the exchange of on-chain deposits and completed payouts through a signed webhook.
# 1. Buy into custody
curl -X POST https://maxmeritexchange.com/api/v1/transactions -H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{ "from_currency":"CAD","to_currency":"USDT","to_network":"TRX","from_amount":500,"payment_method":"interac",
"payout_type":"custody","custody_customer_ref":"user_1001","customer":{"email":"alex@example.com","country":"CA"} }'
# 2. Check the balance
curl https://maxmeritexchange.com/api/v1/custody/accounts?customer_ref=user_1001 -H "x-api-key: $KEY"
# 3. Withdraw to any address
curl -X POST https://maxmeritexchange.com/api/v1/custody/accounts/cust_…/payouts -H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{ "address":"TXyz…", "amount": 250 }'What the custody provider implements
The exchange talks to its custody provider through a small contract documented in docs/CUSTODY-NAKOPAY.md in the repository: create account, read balance, request payout, and a webhook back to /api/v1/webhooks/nakopay signed with the shared secret. Until the provider credentials are configured, custody runs in sandbox with simulated addresses and balances, so partner integrations can be built and tested end to end today.
Errors and limits
{ "error": { "code": "amount_below_minimum", "message": "Minimum is 30 CAD.", "details": { "min": 30, "max": 25000, "currency": "CAD" } } }| 400 | invalid_json, invalid_request |
| 401 | unauthorized, invalid_api_key, invalid_widget_key, invalid_signature |
| 403 | partner_suspended, country_restricted, custody_disabled |
| 404 | transaction_not_found, estimate_not_found, custody_account_not_found |
| 409 | invalid_transition, transaction_closed, custody_account_frozen |
| 422 | validation_error, unsupported_currency, unsupported_pair, unsupported_network, unsupported_payment_method, amount_below_minimum, amount_above_maximum, invalid_address, insufficient_balance |
| 429 | rate_limited: 600 requests/minute per secret key, 120 per widget key per IP, 120 per IP unauthenticated |
| 5xx | internal_error, custody_error, custody_unavailable |
Quotes are valid for 2 minutes. Online limits default to 30 to 25,000 CAD per transaction, with identity verification above 1,000 CAD. Partners can request higher limits.
Admin dashboard
Everything above is operated from /admin: live transactions with status overrides and KYC decisions, partners and API keys, coins, networks, fiat currencies and payment methods, fees and spread, live rate feeds, custody accounts and payouts, webhook deliveries with replay, site content, and a full audit log. Sign in with the credentials from ADMIN_EMAIL / ADMIN_PASSWORD.