# MutoPay — Full Documentation > Crypto payment gateway. Merchants accept any token, receive their preferred stablecoin. REST API, HMAC-signed webhooks, six-chain support (Ethereum, Base, BSC, Polygon, Arbitrum, TON). Source site: https://mutopay.com Interactive API reference: https://mutopay.com/api/docs OpenAPI spec: https://mutopay.com/api/openapi.json --- # Getting Started Source: https://mutopay.com/docs/getting-started/ 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). ## 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/ 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. 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/ 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. | | `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`. | | 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. --- # Webhooks Source: https://mutopay.com/docs/webhooks/ 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": "0xabc...", "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. - **`tx_hash`** is the on-chain destination transaction (where the stablecoin arrived in your wallet). - **`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 From [Settings → Channels](https://mutopay.com/dashboard/settings) click **Send test webhook**. MutoPay will POST a signed sample payload to your configured URL — same shape, same headers, same signing algorithm as production. ## 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/ 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