Documentation

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.

Base URL: https://pay.mevcore.com/v1
  • Format: JSON requests and responses, UTF-8.
  • Authentication: a secret key in the Authorization: Bearer header.
  • Amounts are always decimal strings ("25.00"), never floats. amount_atomic is an integer string in the asset's smallest unit.
  • Full specification: OpenAPI 3.1. Open it in Swagger UI, Redoc or Postman.

Quick start

  1. Sign up and create an organization and a store.
  2. Add a watch-only wallet with your public TRON or TON addresses. Seed phrases and private keys are never needed or accepted.
  3. Enable at least one payment method: USDT TRC20, USDT TON or TON.
  4. Under Developers → API keys, create a test key sk_test_… with the payments:write and payments:read scopes. The secret is shown once.
  5. 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_..."
Important: the customer returning to 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.

Keep secret keys on your server only. Never put them in a browser, a mobile app or a public repository. Keys can be rotated and revoked in the dashboard.
ScopeGrants
payments:readGET /payments, GET /payments/{id}, GET /payment-methods
payments:writePOST /payments, POST /payments/{id}/expire
transactions:readGET /transactions
wallets:readGET /wallets
wallets:writePOST /wallets
webhooks:readGET /webhook-endpoints
webhooks:writePOST /webhook-endpoints, POST /webhook-endpoints/{id}/test
reports:readGET /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

FieldTypeDescription
amount *stringPrice in currency, as a decimal string with up to 8 decimals.
currency *stringUSD, EUR, UAH, USDT or TON. Fiat amounts are converted at the rate in effect when the invoice is issued.
store_idstringStore ID. Optional when the key is bound to a store.
external_order_idstringYour order ID, echoed back in webhooks.
descriptionstringDescription shown to the customer, up to 500 characters.
success_url, cancel_urluriWhere to send the customer after paying or cancelling.
customer_emailemailCustomer email.
assetstringUSDT_TRC20, USDT_TON or TON. If omitted, the customer chooses on the checkout page.
localestringCheckout page language: uk, en, ru or de.
ttl_minutesintegerPayment window from 5 to 1440 minutes; default 30.
metadataobjectUp 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

StatusMeaningWhat to do
CREATEDPayment created, customer has not chosen an asset yetwait
AWAITING_PAYMENTAddress, amount and memo (if any) issuedwait
DETECTEDMatching transfer seen on-chain, not final yetshow “payment received, confirming”
CONFIRMINGWaiting for the required confirmationswait
PAIDFull amount received and finalfulfil the order
OVERPAIDMore than expected received, finalfulfil, and refund the difference yourself if needed
PARTIALLY_PAIDPart of the amount received, window still openwait, or ask the customer to send the rest
UNDERPAIDWindow closed with less than expectedcontact the customer
EXPIREDWindow closed, nothing receivedcancel, or create a new payment
AMBIGUOUSTransfer could belong to several paymentsresolve manually in the dashboard
REVIEW_REQUIREDNeeds manual review: late payment, wrong asset or a risk ruleresolve in the dashboard
FAILEDTerminal failure, for example a reverted transferdo not fulfil
REFUNDEDA 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 pathScopeDescription
POST/v1/paymentspayments:writeCreate a payment
GET/v1/paymentspayments:readList payments
GET/v1/payments/{id}payments:readPayment with transactions and events
POST/v1/payments/{id}/expirepayments:writeClose the payment window
GET/v1/payment-methodspayments:readAssets and networks enabled for a store
GET/v1/transactionstransactions:readIncoming transfers to your addresses
GET/v1/walletswallets:readWatch-only wallets
POST/v1/walletswallets:writeRegister a wallet (address pool)
GET/v1/webhook-endpointswebhooks:readList webhook endpoints
POST/v1/webhook-endpointswebhooks:writeCreate a webhook endpoint (secret returned once)
POST/v1/webhook-endpoints/{id}/testwebhooks:writeSend a webhook.test event
GET/v1/reports/transactionsreports:readReport 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

EventSent when
payment.detecteda transfer is seen on-chain
payment.confirmingconfirmations are accumulating
payment.paidpaid in full and final
payment.overpaidoverpaid, transfer final
payment.partially_paidpart of the amount received
payment.underpaidwindow closed with an underpayment
payment.expiredwindow closed with no payment
payment.review_requiredmanual review needed
payment.ambiguousthe transfer matches several payments
payment.failedterminal failure
payment.refundeda refund was recorded
webhook.testtest 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:

  1. Verify the signature over the raw body (before JSON parsing) with a constant-time comparison.
  2. Reject timestamps older or newer than 300 seconds.
  3. De-duplicate by event id, because the same event can arrive more than once. Make order fulfilment idempotent.
  4. Check livemode, external_order_id, amount and currency against your order.
  5. Answer 2xx within 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."] }
  }
}
HTTPCodeReason
400idempotency_key_required, invalid_idempotency_keyThe idempotency key is missing or has an invalid format
401missing_or_malformed_api_key, invalid_api_keyNo key was sent, or the key is invalid or revoked
403insufficient_scopeThe key lacks the required scope
403organization_suspendedThe organization is suspended
404not_foundThe object does not exist or belongs to another organization
409request_in_progress, idempotency_conflictThe same request is still being processed
409invalid_transitionThe action is not allowed in the payment's current status
422validation_errorInvalid fields, see fields
422idempotency_key_reusedThe key was already used with a different body
429rate_limitedRate 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 with livemode: 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 → PAID and your server receives real signed webhooks without touching any blockchain. Send a smaller or larger amount to test PARTIALLY_PAID and OVERPAID.
  • Test event. POST /v1/webhook-endpoints/{id}/test, or the button in the dashboard, sends webhook.test so you can check connectivity and signature verification.
  • Local development. Use a tunnel (ngrok, cloudflared) to receive webhooks on your machine.

Going live

  1. Register your real watch-only wallets and verify the addresses in the dashboard.
  2. Create an sk_live_ key with the minimum scopes and a separate live webhook endpoint with a new secret.
  3. Make sure your handler verifies the signature and livemode: true, and is idempotent.
  4. Make one small real payment end to end before offering the payment method to customers.
  5. 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

  1. Copy the module's modules/ folder into the WHMCS root, merging it with the existing one.
  2. In System Settings → Payment Gateways, activate Crypto Payment Gateway (USDT / TON).
  3. Enter the API URL https://pay.mevcore.com (without /v1), a key with the payments:read and payments:write scopes, the store ID (optional for a store-bound key) and the webhook secret. The “Test mode” checkbox must match the key type.
  4. In the MEVPAY dashboard, add a webhook for https://<your-whmcs>/modules/gateways/callback/cryptogateway.php.

Paymenter

  1. Copy extensions/Gateways/CryptoGateway/ into Paymenter. No Composer changes are needed, because the SDK is bundled.
  2. Run php artisan optimize:clear if you cache routes or config.
  3. In Admin → Gateways, add a CryptoGateway gateway and fill in the same settings as for WHMCS.
  4. Add a webhook for https://<your-paymenter>/extensions/gateways/cryptogateway/webhook and send a test event.

Plans and fees

PlanMonthly feeFee 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 →