# MutoPay: Full Documentation > Crypto payment gateway. Merchants accept any token and receive their preferred stablecoin. REST API, HMAC-signed webhooks, six-chain support (Ethereum, Base, BSC, Polygon, Arbitrum, TON). Source site: https://mutopay.com API reference (Markdown): https://mutopay.com/api/docs.md OpenAPI spec: https://mutopay.com/api/openapi.json Interactive API reference: https://mutopay.com/api/docs --- # Getting Started Source: https://mutopay.com/docs/getting-started.md Accept your first crypto payment in five minutes. Create a payment link, redirect your customer, and receive settlement in your preferred stablecoin. MutoPay is a crypto payment gateway. Your customer pays in any supported token on any supported chain; you receive your preferred stablecoin in your wallet. This guide takes you from zero to a working payment link in five minutes. ## What you need 1. A MutoPay account. Sign up at [mutopay.com/dashboard/register](https://mutopay.com/dashboard/register). 2. A settlement wallet address and preferred stablecoin/chain (set in [Settings](https://mutopay.com/dashboard/settings)). 3. A **channel API key** (prefix `ep_`). Every integration has its own channel. Create one at [Settings → Channels](https://mutopay.com/dashboard/settings). ## Test before you go live When creating a channel, choose **Test** instead of Production to get a sandbox channel. Test channels mint keys with the `ep_test_` prefix and never touch a chain. Perfect for wiring up your integration end-to-end before accepting real funds. What works the same as production: - The same `POST /api/payments` request. No separate base URL. - Real HMAC-SHA256 signed webhooks, with the same retry schedule (5 attempts, exponential backoff). Payloads carry an extra `"test": true` field so your handler can branch if it needs to. - Your `redirect_url` fires as it would in production. What's different: - The hosted payment page shows a **TEST MODE** banner with four buttons (**Simulate complete**, **fail**, **expire**, **underpaid**) that fire the matching webhook on demand. No wallet connection, no on-chain interaction. - Test payments are excluded from your dashboard stats and volume totals. - The `mode` flag is **permanent on a channel**. To go live, create a separate Production channel and swap the key in your environment. Same model as Stripe's `sk_test_` / `sk_live_`. Use a test channel for: webhook signature verification, status branching in your handler, idempotency under retry, and `redirect_url` flow. Then create a production channel and replace the key. ## Create a payment ```bash curl -X POST https://mutopay.com/api/payments \ -H "X-API-Key: ep_your_channel_api_key" \ -H "Content-Type: application/json" \ -d '{ "amount_usd": 25.00, "description": "Order #1042", "external_id": "order_1042", "callback_url": "https://yourstore.com/thanks" }' ``` Response: ```json { "id": "pay_abc123", "status": "pending", "url": "https://mutopay.com/pay/pay_abc123", "expires_at": "2026-04-21T14:30:00Z" } ``` Redirect your customer to the `url`. They pick a token, connect their wallet (or scan a QR code for manual transfers), and the hosted page handles the rest. ## Receive the result Two options (use both in production): 1. **Webhook** (recommended). Configure your endpoint in the dashboard. MutoPay POSTs to it with an HMAC-SHA256 signature when the payment completes, fails, or expires. See [Verify a webhook signature](/docs/verify-webhook-signature/). 2. **Polling**. `GET https://mutopay.com/api/payments/{id}/status` returns the current `status`. Useful as a fallback or for the browser "thanks" page. ## What happens on-chain - Customer pays in USDC on BSC → MutoPay executes a cross-chain swap → you receive USDC on Polygon (for example). Fees are deducted before settlement. - Direct transfers (customer pays the same token/chain you want to receive) skip the swap. ## Next steps - [Authentication](/docs/authentication/): channel keys vs. master key vs. JWT. - [Create a Payment](/docs/create-payment/): full request/response reference with every parameter. - [Webhooks](/docs/webhooks/): events, payload shape, retry schedule. - [Interactive API reference](https://mutopay.com/api/docs): try every endpoint in your browser. --- # Authentication Source: https://mutopay.com/docs/authentication.md MutoPay has three authentication methods: channel API keys for creating payments, master API keys for account management, and JWTs for the browser dashboard. This page explains when to use which. MutoPay has three authentication methods. Most integrations only need the first two. | Credential | Prefix | Header | Use for | |---|---|---|---| | Channel API key | `ep_` | `X-API-Key: ep_...` | Creating payments from a specific integration (WooCommerce, mobile app, SaaS billing) | | Master API key | `msk_` | `Authorization: Bearer msk_...` | Headless account management: list payments, manage channels, change settlement settings | | Browser JWT | - | `Authorization: Bearer ` | The dashboard at `mutopay.com/dashboard`. Issued by Google Sign-In. You will not usually handle these directly. | ## Channel API keys **One channel per integration.** Each channel has its own API key, webhook URL, and webhook secret. Revoke or rotate one integration without affecting the others. Create a channel at [Settings → Channels](https://mutopay.com/dashboard/settings). The full key is shown **once**; store it in your secret manager. Channels come in two modes, picked at creation: - **Production**: mints keys with the `ep_` prefix. Real on-chain payments, real settlement. - **Test**: mints keys with the `ep_test_` prefix. Sandbox channel for integration testing: real signed webhooks, no chain interaction, simulate-outcome buttons on the payment page. See [Getting Started → Test before you go live](/docs/getting-started/#test-before-you-go-live). Mode is permanent on a channel. To go live, create a separate production channel and swap the key in your environment. Use the channel key on `POST /api/payments`: ```http POST /api/payments HTTP/1.1 Host: mutopay.com X-API-Key: ep_live_4b8f... Content-Type: application/json {"amount_usd": 25.00, "description": "Order #1042"} ``` A channel key **cannot**: - List or read other channels' payments - Rotate itself - Change account settings If you need those, use a master key. ## Master API keys **One per merchant account.** Grants full access to `/api/merchant/*` endpoints: list all payments, manage channels, update settlement wallet/token, etc. Generate at [Settings → Master API Key](https://mutopay.com/dashboard/settings). Shown once. Store in your secret manager. Rotate or revoke anytime. ```http GET /api/merchant/payments HTTP/1.1 Host: mutopay.com Authorization: Bearer msk_live_9c2e... ``` A master key **cannot rotate or revoke itself**: that requires a browser JWT session. This is intentional: if your master key leaks, you (the human) can always revoke it from the dashboard, but an attacker holding the leaked key cannot lock you out by rotating it. ## Which key should I use? - **Building a plugin or SDK?** → channel key. One key per install. - **Writing a server-side automation that needs to see all your payments?** → master key. - **Creating payments from multiple distinct integrations?** → one channel per integration. If unsure, start with a channel key. Upgrade to master only when you need account-wide access. ## Suspended accounts If your merchant account is suspended, both channel and master keys return **HTTP 403** with `{"error": "merchant suspended"}`. Contact support to resolve. ## See also - [Master keys vs channel keys](/docs/master-keys-vs-channel-keys/): deeper comparison and rotation guidance. - [Getting Started](/docs/getting-started/) --- # Create a Payment Source: https://mutopay.com/docs/create-payment.md Full reference for POST /api/payments. Every field, every response, and copy-paste examples in cURL, Node.js, and Python. `POST https://mutopay.com/api/payments` creates a payment link. Authenticate with a channel API key in the `X-API-Key` header. ## Request ```http POST /api/payments HTTP/1.1 Host: mutopay.com X-API-Key: ep_live_4b8f... Content-Type: application/json ``` ### Body | Field | Type | Required | Description | |---|---|---|---| | `amount_usd` | number | yes* | Amount in USD. Required unless `amount_original` + `currency` are supplied. There is a per-channel minimum (typically a few dollars), recompute hourly. Smaller values return 400 with the exact threshold in the `message`. | | `amount_original` | number | no | Amount in the original currency. Used with `currency`. | | `currency` | string | no | ISO-4217 code (`EUR`, `GBP`, `JPY`, ...). Defaults to `USD`. | | `description` | string | no | Human-readable description shown to the customer on the payment page. | | `external_id` | string | no | Your order/invoice ID. Returned on webhooks for reconciliation. | | `callback_url` | string | no | Where to send the customer after payment completes. | | `customer_email` | string | no | For receipt emails and dispute resolution. | | `expires_at` | ISO-8601 string | no | Override the default 30-minute expiry. Max 7 days. | | `metadata` | object | no | Arbitrary JSON metadata, echoed back on webhooks. ≤ 4 KB. | ## Response, 201 Created ```json { "id": "pay_abc123", "status": "pending", "url": "https://mutopay.com/pay/pay_abc123", "amount_usd": 25.00, "currency": "USD", "description": "Order #1042", "external_id": "order_1042", "expires_at": "2026-04-21T14:30:00Z", "created_at": "2026-04-21T14:00:00Z" } ``` Redirect your customer to `url`. ## Examples ### cURL ```bash curl -X POST https://mutopay.com/api/payments \ -H "X-API-Key: $MUTOPAY_CHANNEL_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount_usd": 25.00, "description": "Order #1042", "external_id": "order_1042", "callback_url": "https://yourstore.com/thanks", "metadata": {"user_id": "u_88124", "tier": "pro"} }' ``` ### Node.js (fetch) ```js const res = await fetch('https://mutopay.com/api/payments', { method: 'POST', headers: { 'X-API-Key': process.env.MUTOPAY_CHANNEL_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount_usd: 25.00, description: 'Order #1042', external_id: 'order_1042', callback_url: 'https://yourstore.com/thanks', }), }); if (!res.ok) throw new Error(`MutoPay error: ${res.status}`); const payment = await res.json(); // Redirect the customer: // res.redirect(payment.url); ``` ### Python (requests) ```python import os import requests res = requests.post( "https://mutopay.com/api/payments", headers={ "X-API-Key": os.environ["MUTOPAY_CHANNEL_KEY"], "Content-Type": "application/json", }, json={ "amount_usd": 25.00, "description": "Order #1042", "external_id": "order_1042", "callback_url": "https://yourstore.com/thanks", }, timeout=10, ) res.raise_for_status() payment = res.json() # Redirect the customer to payment["url"] ``` ## Non-USD pricing Pass `amount_original` + `currency` for merchants pricing in EUR/GBP/etc. The FX rate is captured at creation time and returned in webhook payloads as `fx_rate`. ```json { "amount_original": 25.00, "currency": "EUR", "description": "Abonnement Pro" } ``` MutoPay quotes the customer in USD equivalent, then settles in your preferred stablecoin. ## Errors | Status | Body | Meaning | |---|---|---| | 400 | `{"error": "invalid_request", "message": "..."}` | Validation error (see `message`). | | 400 | `{"error": "invalid_request", "message": "Minimum invoice on this channel is $N.NN."}` | `amount_usd` is below the channel's minimum. Show the value from `message` to the customer or bump your minimum order size. | | 401 | `{"error": "unauthorized"}` | Missing or invalid API key. | | 403 | `{"error": "merchant suspended"}` | Your account is suspended. Contact support. | | 429 | `{"error": "rate_limited"}` | Too many requests. Retry with exponential backoff. | ## See also - [Webhooks](/docs/webhooks/): what you get back after the customer pays. - [Payment statuses](/docs/payment-statuses/): state machine reference. --- # Managing Channels Source: https://mutopay.com/docs/managing-channels.md Channels are the unit of separation in MutoPay. One per integration, each with its own API key, webhook, and (optionally) its own settlement wallet. This guide walks through every channel setting in the dashboard. A channel is a slot for one integration. Your WooCommerce store is one channel, your mobile app is another, your Telegram bot is a third. Each channel has its own API key (`ep_...`), its own webhook URL and secret, and can optionally override your account-wide settlement wallet. If you only have one integration, you can stay on the default channel and ignore the rest of this page. Once you have two, channels start paying for themselves. ## Creating a channel 1. Go to [Settings → Channels](https://mutopay.com/dashboard/settings). 2. Click **New channel**. 3. Pick a name. This is for you, customers never see it. Use what helps you tell channels apart in webhook logs: `woocommerce-prod`, `mobile-ios`, `tg-bot-test`. 4. Choose **Production** or **Test**. Test channels mint keys with the `ep_test_` prefix, never touch a chain, and ship the same webhook events with a `"test": true` field. Use one test channel per environment. 5. Click **Create**. You get the full API key once. Copy it. We store only a hash, you cannot read it again. ## Settings on every channel | Setting | What it controls | Customer-facing | |---------|------------------|-----------------| | Name | Internal label in the dashboard | No | | Display name | Header on the `/pay/{id}` page | Yes | | Tagline | Subtitle on the `/pay/{id}` page | Yes | | Webhook URL | Where we POST events | No | | Webhook secret | HMAC key for signature verification | No | | Settlement override | Per-channel wallet, token, and chain | Indirect | Display name and tagline are useful when one MutoPay account fronts multiple brands. Leave them blank and the payment page falls back to your merchant-level business name and tagline. ## Settlement overrides By default, every payment settles to the wallet, token, and chain set in [Settings → Settlement](https://mutopay.com/dashboard/settings). A channel can override all three as one atomic bundle. Example: your main account settles to USDC on Base, but your Telegram bot channel settles to USDT on TON. To set an override: 1. Open the channel. 2. Click **Settlement override**. 3. Pick a token, a chain, and paste the destination wallet address. 4. Save. The override applies to every new payment created with that channel's API key, including payments created via the dashboard with `channel_id` set. To remove an override, click **Use account default**. The channel falls back to your merchant-level settings on the next payment. ## Rotating an API key If a key leaks, rotate it from the channel page (**Regenerate API key**). The new key is shown once. The old key stops working immediately, any in-flight payments already created with the old key continue to settle normally, but new requests with the old key get 401. If you cannot reach the dashboard, use the master key (`msk_...`) to rotate via `POST /api/merchant/channels/{id}/regenerate-api-key`. See [Master Keys vs Channel Keys](/docs/master-keys-vs-channel-keys/). ## Testing a webhook On the channel page, **Test webhook** fires a sample `payment.completed` event with `"test": true` to your configured URL. It uses the channel's real webhook secret, so a passing test means your signature verification works. The response shows the delivery status code and any error. A 2xx counts as delivered. Anything else triggers the same retry schedule as a real webhook: 5 attempts, exponential backoff (1m, 5m, 30m, 2h, 12h). ## Revoking a channel **Revoke API key** invalidates the key without deleting the channel. Existing payments stay accessible in your dashboard and webhook history; new requests with that key get 401. Use this when you've shut down an integration but want to keep its historical data. There is no hard delete. If you need a channel removed entirely, contact [support@mutopay.com](mailto:support@mutopay.com). --- # Webhooks Source: https://mutopay.com/docs/webhooks.md MutoPay sends an HMAC-SHA256 signed webhook when a payment completes, fails, expires, or needs attention. This page documents every event, the payload shape, and the delivery/retry guarantees. MutoPay sends a webhook to your configured URL whenever a payment changes to a terminal state or requires your attention. Payloads are signed with HMAC-SHA256. Always verify the signature before trusting the data. See [Verify a webhook signature](/docs/verify-webhook-signature/). ## Configure Set the webhook URL and retrieve the webhook secret per-channel at [Settings → Channels](https://mutopay.com/dashboard/settings). Each channel has its own URL and secret. ## Events | Event | When it fires | |---|---| | `payment.completed` | Payment succeeded, funds settled in your wallet | | `payment.failed` | Payment attempt failed (on-chain revert, provider rejection, etc.) | | `payment.expired` | Payment was not completed before `expires_at` | | `payment.underpaid` | Customer sent less than the required amount | | `payment.kyc_required` | The swap provider requested customer KYC. Payment is paused, not terminal | | `payment.needs_manual_check` | Unknown provider status. MutoPay support will investigate | `kyc_required` and `needs_manual_check` are non-terminal. When they resolve (e.g. KYC cleared → `processing` → `completed`), **no additional webhook fires for the recovery**: the eventual `payment.completed` or `payment.failed` is your signal. ## Payload ```json { "event": "payment.completed", "payment_id": "pay_abc123", "status": "completed", "amount_usd": 54.23, "amount_original": 50, "fx_rate": 1.0846, "currency": "EUR", "src_token": "USDC", "src_chain_id": "56", "src_amount": null, "dest_token": "USDC", "dest_chain_id": "137", "dest_amount": "54230000", "dest_decimals": 6, "external_id": "order_1042", "tx_hash": "fa67b42db29d0b54ffd778fdffcc1c65ae5c137d2fef0bd66f34b3a9f46d77c5", "payer_deposits": [ { "tx_hash": "fa67b42db29d0b54ffd778fdffcc1c65ae5c137d2fef0bd66f34b3a9f46d77c5", "amount": "1035691177", "amount_human": "1.04 TON", "received_at": 1779091349, "source_address": "UQABCxyz...EnD" } ], "failure_reason": null, "completed_at": "2026-04-21T12:00:00Z", "timestamp": "2026-04-21T12:00:01Z" } ``` ### Field notes - **`dest_amount`** is raw token base units. Divide by `10^dest_decimals` for human-readable: `54230000 / 10^6 = 54.23 USDC`. - **`external_id`** is the value you sent when creating the payment. Use it for reconciliation. - **`payer_deposits`** is the list of customer inbound transactions, newest first. Each item is one TonAPI trace with the payer wallet (`source_address`), raw `amount`, formatted `amount_human`, and `received_at` (unix seconds). Use this for reconciliation against the customer's wallet history. On underpaid payments that get topped up, you see every partial deposit here. - **`tx_hash`** is the most recent payer transaction hash, kept for backward compatibility. **Deprecated**: use `payer_deposits[0].tx_hash` instead. - **`failure_reason`** is populated for `payment.failed` and sometimes `payment.expired`. - **`timestamp`** is when MutoPay fired the webhook. **`completed_at`** is when settlement actually happened. ## Delivery guarantees - Delivery is **at-least-once**. You may receive the same webhook more than once; idempotency is your responsibility (use `payment_id` + `event` as the dedupe key). - Your endpoint must respond with a **2xx status** within 30 seconds to be considered delivered. - Non-2xx responses, timeouts, and network errors are retried up to **5 times** with exponential backoff: | Attempt | Delay after previous failure | |---|---| | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | (final) | 12 hours | After the final attempt fails, delivery is abandoned. Use the merchant dashboard or `GET /api/merchant/payments/:id` to reconcile. ## Request headers ```http POST /your/webhook HTTP/1.1 Content-Type: application/json X-MutoPay-Signature: sha256= User-Agent: MutoPay-Webhook/1.0 ``` ## Security - **Always verify the signature** before processing the payload. See [Verify a webhook signature](/docs/verify-webhook-signature/). - **Never log the webhook secret.** Rotate it via the channel settings if you suspect compromise. - Your endpoint should be **HTTPS**. ## Test your endpoint Two ways: 1. **One-off ping**. From [Settings → Channels](https://mutopay.com/dashboard/settings) click **Send test webhook**. MutoPay POSTs a signed sample payload to your configured URL with the same shape, same headers, and same signing algorithm as production. Good for a quick sanity check. 2. **Full sandbox**. Create a **Test channel** (prefix `ep_test_`). Open any test payment in the browser and use the **Simulate complete / fail / expire / underpaid** buttons to fire the matching event. Real signed payloads, real retry pipeline, real `redirect_url`, with an extra `"test": true` field added so your handler can branch. This is the right way to verify status-branching logic and idempotency under retry. See [Getting Started → Test before you go live](/docs/getting-started/#test-before-you-go-live). ## See also - [Verify a webhook signature](/docs/verify-webhook-signature/): Node, Python, PHP examples. - [Payment statuses](/docs/payment-statuses/): status transition reference. --- # Verify a Webhook Signature Source: https://mutopay.com/docs/verify-webhook-signature.md MutoPay signs every webhook with HMAC-SHA256. This page shows how to verify the X-MutoPay-Signature header in Node.js, Python, and PHP. Copy-paste, no dependencies. Every MutoPay webhook includes an `X-MutoPay-Signature` header containing an HMAC-SHA256 digest of the raw request body. Verify it before trusting the payload. ## The algorithm ``` signature = HMAC-SHA256(webhook_secret, raw_request_body) expected = "sha256=" + hex(signature) valid = constant_time_equal(expected, header_value) ``` Three things that matter: 1. Use the **raw** request body bytes. Do **not** parse JSON first and re-serialize: whitespace and key order will change and the signature will fail. 2. Use the webhook secret **for this specific channel** (each channel has its own secret). Find it at [Settings → Channels](https://mutopay.com/dashboard/settings). 3. Use a **constant-time comparison** (`crypto.timingSafeEqual` / `hmac.compare_digest` / `hash_equals`), not `===` or `strcmp`. Regular comparison leaks timing information and enables signature forgery. ## Node.js (Express) ```js import crypto from 'node:crypto'; import express from 'express'; const app = express(); const WEBHOOK_SECRET = process.env.MUTOPAY_WEBHOOK_SECRET; // IMPORTANT: use express.raw (not express.json); we need the raw bytes. app.post( '/webhooks/mutopay', express.raw({ type: 'application/json' }), (req, res) => { const header = req.header('X-MutoPay-Signature') ?? ''; const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.body).digest('hex'); const ok = header.length === expected.length && crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected)); if (!ok) return res.status(401).send('invalid signature'); const event = JSON.parse(req.body.toString('utf8')); // handle event.event / event.payment_id / event.status ... res.status(200).send('ok'); } ); ``` ## Node.js (Cloudflare Workers / Hono) ```ts async function verify(secret: string, body: string, header: string | null) { if (!header) return false; const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body)); const expected = 'sha256=' + Array.from(new Uint8Array(sig)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); // Constant-time compare if (expected.length !== header.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ header.charCodeAt(i); return diff === 0; } app.post('/webhooks/mutopay', async (c) => { const body = await c.req.text(); const ok = await verify( c.env.MUTOPAY_WEBHOOK_SECRET, body, c.req.header('X-MutoPay-Signature') ?? null ); if (!ok) return c.text('invalid signature', 401); const event = JSON.parse(body); // handle event ... return c.text('ok', 200); }); ``` ## Python (Flask) ```python import hmac import hashlib import os from flask import Flask, request, abort app = Flask(__name__) WEBHOOK_SECRET = os.environ["MUTOPAY_WEBHOOK_SECRET"].encode() @app.post("/webhooks/mutopay") def mutopay_webhook(): body = request.get_data() # raw bytes header = request.headers.get("X-MutoPay-Signature", "") expected = "sha256=" + hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, header): abort(401) event = request.get_json() # handle event["event"] / event["payment_id"] / event["status"] ... return "", 200 ``` ## PHP ```php