Криптоплатежі у WordPress і WooCommerce через API

Готового плагіна Moonvex для WordPress і WooCommerce поки немає — підключення робиться власним кодом через API. Нижче — невеликий власний плагін: спосіб оплати WooCommerce, який створює платіж і веде клієнта на сторінку оплати, і REST-маршрут для webhook із перевіркою підпису.

Покроково

  1. 1.Отримайте ключ API і секрет webhook

    У кабінеті Moonvex відкрийте «Інтеграція» → «Створити ключ» → тип «Секретний» (sk_live_…). Ключ показується один раз — збережіть його на сервері в MOONVEX_SECRET_KEY. Там само додайте webhook на https-адресу вашого сервера і збережіть секрет підпису в MOONVEX_WEBHOOK_SECRET. У WordPress задайте їх константами у wp-config.php — не в налаштуваннях плагіна в базі.

    // wp-config.php — ключі поза базою даних і поза репозиторієм теми
    define('MOONVEX_SECRET_KEY', 'sk_live_...');
    define('MOONVEX_WEBHOOK_SECRET', 'whsec_...');
  2. 2.Додайте спосіб оплати WooCommerce

    Клас WC_Gateway_Moonvex успадковує WC_Payment_Gateway. У process_payment він створює платіж через wp_remote_post і повертає redirect на paymentUrl. Ціна в USD передається як currency: USD з монетою оплати asset: USDT. Номер спроби зберігається в мета-полі замовлення і зростає лише після успішного створення.

    <?php
    /**
     * Plugin Name: Moonvex Crypto Payments (custom)
     * Description: Власна інтеграція WooCommerce з API Moonvex
     */
    // wp-content/plugins/moonvex-custom/moonvex-custom.php
    
    add_action('plugins_loaded', function () {
        if (!class_exists('WC_Payment_Gateway')) {
            return;
        }
    
        class WC_Gateway_Moonvex extends WC_Payment_Gateway
        {
            public function __construct()
            {
                $this->id = 'moonvex';
                $this->method_title = 'Moonvex';
                $this->method_description = 'Оплата в USDT через сторінку оплати Moonvex';
                $this->has_fields = false;
                $this->init_form_fields();
                $this->init_settings();
                $this->title = $this->get_option('title', 'Криптовалюта (USDT)');
                add_action('woocommerce_update_options_payment_gateways_' . $this->id, [$this, 'process_admin_options']);
            }
    
            public function process_payment($order_id)
            {
                $order = wc_get_order($order_id);
                if ($order->get_currency() !== 'USD') {
                    wc_add_notice('Оплата криптовалютою доступна для цін у USD', 'error');
                    return ['result' => 'failure'];
                }
    
                // номер спроби зростає лише після успішного створення — повтор після таймауту ідемпотентний
                $attempt = (int) $order->get_meta('_moonvex_attempts') + 1;
                $res = wp_remote_post('https://api.moonvex.org/v1/payments', [
                    'timeout' => 15,
                    'headers' => [
                        'Authorization' => 'Bearer ' . MOONVEX_SECRET_KEY,
                        'Idempotency-Key' => 'wc-order-' . $order->get_id() . '-' . $attempt,
                        'Content-Type' => 'application/json',
                    ],
                    'body' => wp_json_encode([
                        'orderId' => (string) $order->get_id(),
                        'amount' => wc_format_decimal($order->get_total(), 2),
                        'currency' => 'USD',
                        'asset' => 'USDT',
                        'network' => 'TRON',
                        'customerEmail' => $order->get_billing_email(),
                    ]),
                ]);
    
                $payment = is_wp_error($res) ? null : json_decode(wp_remote_retrieve_body($res), true);
                if (!$payment || empty($payment['paymentUrl'])) {
                    wc_add_notice('Не вдалося створити платіж, спробуйте ще раз', 'error');
                    return ['result' => 'failure'];
                }
    
                $order->update_meta_data('_moonvex_attempts', $attempt);
                $order->update_meta_data('_moonvex_payment_id', $payment['id']);
                $order->update_status('pending', 'Moonvex: ' . $payment['id']);
                $order->save();
    
                return ['result' => 'success', 'redirect' => $payment['paymentUrl']];
            }
        }
    
        add_filter('woocommerce_payment_gateways', function ($gateways) {
            $gateways[] = 'WC_Gateway_Moonvex';
            return $gateways;
        });
    });
  3. 3.Надішліть клієнта на paymentUrl

    paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. WooCommerce сам перенаправить покупця за redirect із process_payment.

  4. 4.Прийміть webhook і перевірте підпис

    Moonvex надсилає POST із заголовком Moonvex-Signature: t=<unix-час>,v1=<підпис>. Підпис — HMAC-SHA256 секретом webhook від рядка «t.сире_тіло» у hex. Рахуйте його за байтами тіла до розбору JSON, порівнюйте за сталий час, відхиляйте події, старші за 5 хвилин. Після зміни секрету 24 години в заголовку два v1 — підходить будь-який. Маршрут реєструється через register_rest_route; адреса для кабінету — /wp-json/moonvex/v1/webhook. Сире тіло — $request->get_body().

    // moonvex-custom.php (продовження)
    // URL для кабінету Moonvex: https://shop.example.com/wp-json/moonvex/v1/webhook
    add_action('rest_api_init', function () {
        register_rest_route('moonvex/v1', '/webhook', [
            'methods' => 'POST',
            'permission_callback' => '__return_true', // доступ перевіряється підписом
            'callback' => function (WP_REST_Request $request) {
                $raw = $request->get_body(); // сире тіло
                if (!moonvex_verify($raw, (string) $request->get_header('Moonvex-Signature'), MOONVEX_WEBHOOK_SECRET)) {
                    return new WP_REST_Response('bad signature', 400);
                }
                moonvex_handle_event(json_decode($raw, true));
                return new WP_REST_Response('ok', 200);
            },
        ]);
    });
    
    function moonvex_verify(string $raw, string $header, string $secret, int $tolerance = 300): bool
    {
        $t = null;
        $sigs = [];
        foreach (explode(',', $header) as $part) {
            [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
            if ($k === 't' && ctype_digit($v)) { $t = (int) $v; }
            if ($k === 'v1') { $sigs[] = $v; }
        }
        if (!$t || abs(time() - $t) > $tolerance) {
            return false;
        }
        $expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
        foreach ($sigs as $s) {
            if (hash_equals($expected, $s)) {
                return true;
            }
        }
        return false;
    }
  5. 5.Позначте замовлення оплаченим рівно один раз

    Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. У прикладі id події зберігається в мета-полі замовлення, а payment_complete() викликається лише якщо !$order->is_paid(). За payment.expired замовлення переходить у статус «Не вдалося».

    // moonvex-custom.php (продовження)
    function moonvex_handle_event(array $event): void
    {
        $order = wc_get_order((int) ($event['data']['orderId'] ?? 0));
        if (!$order || $order->get_payment_method() !== 'moonvex') {
            return;
        }
        $seen = '_moonvex_event_' . $event['id'];
        if ($order->get_meta($seen)) {
            return; // цю подію вже оброблено
        }
    
        switch ($event['type']) {
            case 'payment.confirmed':
            case 'payment.overpaid':
                if (!$order->is_paid()) {
                    $order->payment_complete($event['data']['id']); // pay_… — номер транзакції замовлення
                }
                break;
            case 'payment.expired':
                if (!$order->is_paid()) {
                    $order->update_status('failed', 'Moonvex: payment expired');
                }
                break;
        }
    
        $order->update_meta_data($seen, time());
        $order->save();
    }
  6. 6.Перевірте інтеграцію

    Тестової мережі в Moonvex немає — усе працює в основних мережах. Кнопка «Надіслати тест» у розділі «Інтеграція» надішле подію webhook.test — так перевіряється підпис. Потім оплатіть невеликий рахунок, наприклад 1 USDT у TRON: це звичайний платіж, гроші надійдуть на баланс. Журнал доставок із кодами відповідей і кнопкою «Повторити» — там само. Увімкніть спосіб оплати в WooCommerce → Налаштування → Платежі.

Підводні камені

  • Готового плагіна Moonvex для WooCommerce поки немає — код із гайду ваш, оновлюйте його разом із магазином.
  • Блокове оформлення замовлення (Checkout block) показує лише шлюзи з інтеграцією для блоків. Для коду з гайду використовуйте класичний шорткод [woocommerce_checkout] або додайте реєстрацію через AbstractPaymentMethodType.
  • Валюта магазину має бути USD (або монета): Moonvex приймає ціну в USD або в криптовалюті, інші фіатні валюти треба перерахувати самостійно.
  • Плагіни безпеки та кешування іноді закривають /wp-json/ — відкрийте маршрут moonvex/v1/webhook для POST.

Часті запитання

Чи є готовий плагін Moonvex для WordPress?

Поки ні. Підключення — через API, як у цьому гайді: близько ста рядків PHP у власному плагіні. Якщо не хочете писати код, рахунки можна виставляти вручну з кабінету Moonvex.

Покупець повернувся на сайт, а замовлення не оплачене — чому?

Повернення на сайт не підтверджує оплату. Замовлення стане оплаченим, коли надійде webhook payment.confirmed — після потрібної кількості підтверджень мережі. Перевірте журнал доставок у розділі «Інтеграція».

Чому amount у відповіді більший за ціну замовлення?

Комісію Moonvex клієнт сплачує зверху, тому amount — підсумок до сплати. Ваша ціна залишається в pricing.amount, ваш дохід за повної оплати — у merchantAmount. Звіряйте замовлення за orderId і pricing.amount, а клієнтові показуйте amount.

Інші гайди

Почніть приймати оплату вже сьогодні

Обліковий запис і перший проєкт за хвилину, без абонентської плати.

Створити обліковий запис