MEVPAY API
MEVPAY is a non-custodial crypto payment gateway. You create a payment through the REST API, the customer pays on our hosted page, and the funds go straight to your own wallet (USDT TRC20, USDT TON or TON). We notify your server with a signed webhook.
https://pay.mevcore.com/v1- Format: JSON requests and responses, UTF-8.
- Authentication: a secret key in the
Authorization: Bearerheader. - Amounts are always decimal strings (
"25.00"), never floats.amount_atomicis an integer string in the asset's smallest unit. - Full specification: OpenAPI 3.1. Open it in Swagger UI, Redoc or Postman.
Quick start
- Sign up and create an organization and a store.
- Add a watch-only wallet with your public TRON or TON addresses. Seed phrases and private keys are never needed or accepted.
- Enable at least one payment method: USDT TRC20, USDT TON or TON.
- Under Developers → API keys, create a test key
sk_test_…with thepayments:writeandpayments:readscopes. The secret is shown once. - Under Developers → Webhooks, add your server's HTTPS endpoint and keep the signing secret
whsec_….
Create a payment. Always send an Idempotency-Key so that a retried request never creates a second payment.
curl -sS https://pay.mevcore.com/v1/payments \ -H "Authorization: Bearer sk_test_..." \ -H "Idempotency-Key: order-10482-1" \ -H "Content-Type: application/json" \ -d '{ "store_id": "store_123", "external_order_id": "10482", "amount": "25.00", "currency": "USD", "description": "VPS Hosting", "success_url": "https://shop.example/orders/10482", "cancel_url": "https://shop.example/cart" }'
Response (abridged):
{
"id": "pay_abc123",
"object": "payment",
"livemode": false,
"status": "CREATED",
"amount": "25",
"currency": "USD",
"checkout_url": "http://localhost:5174/p/pay_abc123",
"expires_at": "2026-10-19T12:30:00+00:00"
}
Redirect the customer to checkout_url and wait for the payment.paid webhook. You can also poll the payment:
curl -sS https://pay.mevcore.com/v1/payments/pay_abc123 \ -H "Authorization: Bearer sk_test_..."
success_url is never proof of payment, because anyone can open that URL. Fulfil the order only after a signature-verified webhook with status PAID or OVERPAID.API keys and scopes
Every request must carry a secret key:
Authorization: Bearer sk_live_... # live mode Authorization: Bearer sk_test_... # test mode
Test keys only see test objects and live keys only see live objects; every object has a livemode field. A key can be bound to a single store: then store_id may be omitted and any other store is rejected.
| Scope | Grants |
|---|---|
payments:read | GET /payments, GET /payments/{id}, GET /payment-methods |
payments:write | POST /payments, POST /payments/{id}/expire |
transactions:read | GET /transactions |
wallets:read | GET /wallets |
wallets:write | POST /wallets |
webhooks:read | GET /webhook-endpoints |
webhooks:write | POST /webhook-endpoints, POST /webhook-endpoints/{id}/test |
reports:read | GET /reports/transactions |
A request without the required scope fails with 403 insufficient_scope.
Idempotency
POST endpoints that create objects accept an Idempotency-Key header (printable ASCII, up to 191 characters). Keys are scoped to the organization and mode (test or live):
- the same key with the same body returns the original response with header
Idempotent-Replayed: true, and no second payment is created; - the same key with a different body returns
422 idempotency_key_reused; - a duplicate sent while the first request is still processing returns
409 request_in_progress, so retry it shortly; - 5xx responses are not stored, so the request can be retried with the same key.
A good key is your order ID plus an attempt number, for example order-10482-1.
Payments
POST/v1/payments — create a payment
| Field | Type | Description |
|---|---|---|
amount * | string | Price in currency, as a decimal string with up to 8 decimals. |
currency * | string | USD, EUR, UAH, USDT or TON. Fiat amounts are converted at the rate in effect when the invoice is issued. |
store_id | string | Store ID. Optional when the key is bound to a store. |
external_order_id | string | Your order ID, echoed back in webhooks. |
description | string | Description shown to the customer, up to 500 characters. |
success_url, cancel_url | uri | Where to send the customer after paying or cancelling. |
customer_email | Customer email. | |
asset | string | USDT_TRC20, USDT_TON or TON. If omitted, the customer chooses on the checkout page. |
locale | string | Checkout page language: uk, en, ru or de. |
ttl_minutes | integer | Payment window from 5 to 1440 minutes; default 30. |
metadata | object | Up to 20 string key/value pairs, returned unchanged. |
GET/v1/payments/{id} — retrieve a payment
Returns the payment with its payment instruction (address, amount, memo), transactions and event history.
GET/v1/payments — list payments
Newest first. Filters: store_id, status, external_order_id, created_from, created_to.
POST/v1/payments/{id}/expire — cancel
Closes the payment window early, for example when the customer cancelled the order.
How payments are matched
The wallet issues a unique combination per invoice: a dedicated address from a pool, a unique amount (for example 25.000417 USDT) or a memo on TON. A single transfer is therefore never credited to two orders. If a transfer could belong to several payments, the payment becomes AMBIGUOUS and goes to manual review.
Payment statuses
| Status | Meaning | What to do |
|---|---|---|
CREATED | Payment created, customer has not chosen an asset yet | wait |
AWAITING_PAYMENT | Address, amount and memo (if any) issued | wait |
DETECTED | Matching transfer seen on-chain, not final yet | show “payment received, confirming” |
CONFIRMING | Waiting for the required confirmations | wait |
PAID | Full amount received and final | fulfil the order |
OVERPAID | More than expected received, final | fulfil, and refund the difference yourself if needed |
PARTIALLY_PAID | Part of the amount received, window still open | wait, or ask the customer to send the rest |
UNDERPAID | Window closed with less than expected | contact the customer |
EXPIRED | Window closed, nothing received | cancel, or create a new payment |
AMBIGUOUS | Transfer could belong to several payments | resolve manually in the dashboard |
REVIEW_REQUIRED | Needs manual review: late payment, wrong asset or a risk rule | resolve in the dashboard |
FAILED | Terminal failure, for example a reverted transfer | do not fulfil |
REFUNDED | A refund was recorded | — |
Only PAID and OVERPAID mean the money is final. We set them only after verifying the network, token contract, address, amount and confirmations: 20 blocks (about a minute) for USDT TRC20, and TON transactions are final almost immediately.
Endpoint reference
| Method and path | Scope | Description |
|---|---|---|
POST/v1/payments | payments:write | Create a payment |
GET/v1/payments | payments:read | List payments |
GET/v1/payments/{id} | payments:read | Payment with transactions and events |
POST/v1/payments/{id}/expire | payments:write | Close the payment window |
GET/v1/payment-methods | payments:read | Assets and networks enabled for a store |
GET/v1/transactions | transactions:read | Incoming transfers to your addresses |
GET/v1/wallets | wallets:read | Watch-only wallets |
POST/v1/wallets | wallets:write | Register a wallet (address pool) |
GET/v1/webhook-endpoints | webhooks:read | List webhook endpoints |
POST/v1/webhook-endpoints | webhooks:write | Create a webhook endpoint (secret returned once) |
POST/v1/webhook-endpoints/{id}/test | webhooks:write | Send a webhook.test event |
GET/v1/reports/transactions | reports:read | Report for from–to: JSON up to 5000 rows, or a full CSV (format=csv) |
Request and response schemas are in openapi.yaml.
Webhooks
Every event is sent to your endpoint as a POST with a JSON body:
{
"id": "evt_123",
"type": "payment.paid",
"created_at": "2026-10-19T12:00:00+00:00",
"livemode": false,
"data": {
"payment_id": "pay_abc123", "store_id": "store_123", "external_order_id": "10482",
"status": "PAID", "amount": "25", "currency": "USD", "expected_amount": "25",
"paid_amount": "25", "paid_asset": "USDT", "network": "TRON",
"txid": "0f3c...", "txids": ["0f3c..."], "paid_at": "2026-10-19T12:00:00+00:00"
}
}
Event types
| Event | Sent when |
|---|---|
payment.detected | a transfer is seen on-chain |
payment.confirming | confirmations are accumulating |
payment.paid | paid in full and final |
payment.overpaid | overpaid, transfer final |
payment.partially_paid | part of the amount received |
payment.underpaid | window closed with an underpayment |
payment.expired | window closed with no payment |
payment.review_required | manual review needed |
payment.ambiguous | the transfer matches several payments |
payment.failed | terminal failure |
payment.refunded | a refund was recorded |
webhook.test | test event from the dashboard or the API |
Headers and signature
Headers: X-Gateway-Event-Id, X-Gateway-Event-Type, X-Gateway-Delivery-Attempt and X-Gateway-Signature: t=<unix>,v1=<hex>, where v1 = HMAC_SHA256(secret, "<t>.<raw body>").
What your handler must do:
- Verify the signature over the raw body (before JSON parsing) with a constant-time comparison.
- Reject timestamps older or newer than 300 seconds.
- De-duplicate by event
id, because the same event can arrive more than once. Make order fulfilment idempotent. - Check
livemode,external_order_id,amountandcurrencyagainst your order. - Answer
2xxwithin 10 seconds and do slow work in the background.
PHP
<?php function verifyGatewaySignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool { $parts = []; foreach (explode(',', $header) as $kv) { [$k, $v] = array_pad(explode('=', trim($kv), 2), 2, ''); $parts[$k] = $v; } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; } if (abs(time() - (int) $parts['t']) > $tolerance) { return false; // replayed or clock skew } $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']); } $raw = file_get_contents('php://input'); if (!verifyGatewaySignature($raw, $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '', getenv('GATEWAY_WEBHOOK_SECRET'))) { http_response_code(400); exit; } $event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); // 1) skip if $event['id'] was already processed 2) handle $event['type'] 3) respond 200 http_response_code(200);
Node.js (Express)
import crypto from 'node:crypto'; import express from 'express'; function verifyGatewaySignature(rawBody, header, secret, tolerance = 300) { const parts = Object.fromEntries( String(header || '').split(',').map((kv) => kv.trim().split('=', 2)), ); if (!/^\d+$/.test(parts.t || '') || !/^[0-9a-f]{64}$/.test(parts.v1 || '')) return false; if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > tolerance) return false; const expected = crypto .createHmac('sha256', secret) .update(`${parts.t}.`) .update(rawBody) // Buffer with the exact bytes received .digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(parts.v1, 'hex')); } const app = express(); // Raw body for this route: re-serialized JSON would not match the signature. app.post('/webhooks/gateway', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyGatewaySignature(req.body, req.get('X-Gateway-Signature'), process.env.GATEWAY_WEBHOOK_SECRET)) { return res.sendStatus(400); } const event = JSON.parse(req.body.toString('utf8')); // 1) skip if event.id was already processed 2) enqueue handling of event.type 3) acknowledge res.sendStatus(200); });
Retries
Failed deliveries are retried after 1 m, 5 m, 15 m, 1 h, 3 h, 6 h, 12 h and 24 h, then moved to a dead-letter queue, where you can retry them from the dashboard. Events can arrive out of order, so rely on status, or fetch GET /v1/payments/{id} when in doubt.
Webhook URLs must be public HTTPS URLs: internal and private IP addresses are blocked.
Errors and rate limits
All errors use the same envelope. fields is present for validation errors only.
{
"error": {
"code": "validation_error",
"message": "The amount field is required.",
"fields": { "amount": ["The amount field is required."] }
}
}
| HTTP | Code | Reason |
|---|---|---|
| 400 | idempotency_key_required, invalid_idempotency_key | The idempotency key is missing or has an invalid format |
| 401 | missing_or_malformed_api_key, invalid_api_key | No key was sent, or the key is invalid or revoked |
| 403 | insufficient_scope | The key lacks the required scope |
| 403 | organization_suspended | The organization is suspended |
| 404 | not_found | The object does not exist or belongs to another organization |
| 409 | request_in_progress, idempotency_conflict | The same request is still being processed |
| 409 | invalid_transition | The action is not allowed in the payment's current status |
| 422 | validation_error | Invalid fields, see fields |
| 422 | idempotency_key_reused | The key was already used with a different body |
| 429 | rate_limited | Rate limit exceeded |
Rate limits apply per API key and per IP. A 429 response carries a Retry-After header. 5xx errors and network failures can safely be retried with the same Idempotency-Key.
Testing
- Test mode.
sk_test_keys create payments withlivemode: false, fully isolated from live data. - Simulate a transfer. Open a test payment in the dashboard (Payments → details) and press “Simulate transfer”. The payment goes through
DETECTED→PAIDand your server receives real signed webhooks without touching any blockchain. Send a smaller or larger amount to testPARTIALLY_PAIDandOVERPAID. - Test event.
POST /v1/webhook-endpoints/{id}/test, or the button in the dashboard, sendswebhook.testso you can check connectivity and signature verification. - Local development. Use a tunnel (ngrok, cloudflared) to receive webhooks on your machine.
Going live
- Register your real watch-only wallets and verify the addresses in the dashboard.
- Create an
sk_live_key with the minimum scopes and a separate live webhook endpoint with a new secret. - Make sure your handler verifies the signature and
livemode: true, and is idempotent. - Make one small real payment end to end before offering the payment method to customers.
- Enable two-factor authentication for every team member.
SDKs and plugins
PHP SDK
cryptogateway/sdk-php supports PHP 8.1+ and needs only ext-curl and ext-json, so it fits into any project.
use CryptoGateway\Client; $client = new Client('sk_test_...', 'https://pay.mevcore.com'); $payment = $client->payments()->create([ 'amount' => '25.00', 'currency' => 'USD', 'external_order_id' => '10482', ], idempotencyKey: 'order-10482-1'); header('Location: ' . $payment['checkout_url']);
TypeScript / Node.js SDK
@cryptogateway/sdk supports Node 18+, has zero dependencies and ships types for every object.
import { CryptoGatewayClient } from '@cryptogateway/sdk'; const client = new CryptoGatewayClient(process.env.GATEWAY_KEY!, { baseUrl: 'https://pay.mevcore.com' }); const payment = await client.payments.create( { external_order_id: '10482', amount: '25.00', currency: 'USD' }, 'order-10482-1', // idempotency key ); console.log(payment.checkout_url);
WHMCS
- Copy the module's
modules/folder into the WHMCS root, merging it with the existing one. - In System Settings → Payment Gateways, activate Crypto Payment Gateway (USDT / TON).
- Enter the API URL
https://pay.mevcore.com(without/v1), a key with thepayments:readandpayments:writescopes, the store ID (optional for a store-bound key) and the webhook secret. The “Test mode” checkbox must match the key type. - In the MEVPAY dashboard, add a webhook for
https://<your-whmcs>/modules/gateways/callback/cryptogateway.php.
Paymenter
- Copy
extensions/Gateways/CryptoGateway/into Paymenter. No Composer changes are needed, because the SDK is bundled. - Run
php artisan optimize:clearif you cache routes or config. - In Admin → Gateways, add a CryptoGateway gateway and fill in the same settings as for WHMCS.
- Add a webhook for
https://<your-paymenter>/extensions/gateways/cryptogateway/webhookand send a test event.
Plans and fees
| Plan | Monthly fee | Fee on paid volume |
|---|---|---|
| Free | $0 | 1% |
| Pro | $15 | 0.5% |
| Business | $49 | 0.2% |
| Enterprise | Custom | |
Fees are never deducted from transfers: the customer pays you the full amount. Charges accumulate in the dashboard (Billing), and once a month we issue an invoice together with the plan fee. Compare plans and estimate your cost →