Приймання криптоплатежів у Ruby on Rails

Як приймати криптовалюту в застосунку на Rails: невеликий модуль на Net::HTTP створює платіж, контролер веде клієнта на сторінку оплати, контролер webhook перевіряє підпис за request.raw_post і позначає замовлення один раз.

Покроково

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

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

    # ключі — у credentials або змінних оточення
    export MOONVEX_SECRET_KEY=sk_live_...
    export MOONVEX_WEBHOOK_SECRET=whsec_...
    
    bin/rails g model ProcessedEvent event_id:string:uniq
  2. 2.Створіть платіж

    Модуль Moonvex надсилає POST /v1/payments через Net::HTTP. Сума — BigDecimal#to_s("F"), рядок без експоненти. У Rails 7+ зовнішній редирект потребує allow_other_host: true.

    # app/services/moonvex.rb
    require "net/http"
    require "json"
    
    module Moonvex
      API = URI("https://api.moonvex.org")
    
      def self.create_payment(order_id:, amount:, attempt: 1)
        uri = URI.join(API, "/v1/payments")
        req = Net::HTTP::Post.new(uri,
          "Authorization" => "Bearer #{ENV.fetch('MOONVEX_SECRET_KEY')}",
          "Idempotency-Key" => "order-#{order_id}-#{attempt}",
          "Content-Type" => "application/json")
        req.body = { orderId: order_id.to_s, amount: amount, currency: "USDT", network: "TRON" }.to_json
    
        res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 15) { |http| http.request(req) }
        body = JSON.parse(res.body)
        raise "Moonvex #{body.dig('error', 'code')}: #{body.dig('error', 'message')}" unless res.is_a?(Net::HTTPSuccess)
    
        body
      end
    end
    
    # app/controllers/orders_controller.rb
    class OrdersController < ApplicationController
      def pay
        order = current_user.orders.find(params[:id])
        # BigDecimal → рядок "25.0", без Float
        payment = Moonvex.create_payment(order_id: order.id, amount: order.total.to_s("F"))
        order.update!(moonvex_payment_id: payment["id"])
        # Rails 7+: зовнішній редирект дозволяється явно
        redirect_to payment["paymentUrl"], allow_other_host: true, status: :see_other
      end
    end
  3. 3.Надішліть клієнта на paymentUrl

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

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

    Moonvex надсилає POST із заголовком Moonvex-Signature: t=<unix-час>,v1=<підпис>. Підпис — HMAC-SHA256 секретом webhook від рядка «t.сире_тіло» у hex. Рахуйте його за байтами тіла до розбору JSON, порівнюйте за сталий час, відхиляйте події, старші за 5 хвилин. Після зміни секрету 24 години в заголовку два v1 — підходить будь-який. skip_forgery_protection вимикає CSRF лише для цього контролера; сире тіло — request.raw_post. Підписи порівнюються ActiveSupport::SecurityUtils.secure_compare.

    # app/controllers/moonvex_webhooks_controller.rb
    class MoonvexWebhooksController < ApplicationController
      skip_forgery_protection # у Moonvex немає CSRF-токена — захищає підпис
      skip_before_action :authenticate_user!, raise: false
    
      def create
        raw = request.raw_post # сире тіло, не params
        return head :bad_request unless verify(raw, request.headers["Moonvex-Signature"], ENV.fetch("MOONVEX_WEBHOOK_SECRET"))
    
        event = JSON.parse(raw)
        if %w[payment.confirmed payment.overpaid].include?(event["type"])
          Order.mark_paid_once!(event["id"], event.dig("data", "orderId"))
        end
        head :ok
      end
    
      private
    
      def verify(raw, header, secret, tolerance: 300)
        parts = header.to_s.split(",").filter_map { |p| p.split("=", 2) if p.include?("=") }
        ts = parts.find { |k, _| k == "t" }&.last
        return false unless ts&.match?(/\A\d+\z/) && (Time.now.to_i - ts.to_i).abs <= tolerance
    
        expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}")
        # secure_compare — порівняння за сталий час
        parts.any? { |k, v| k == "v1" && ActiveSupport::SecurityUtils.secure_compare(expected, v) }
      end
    end
    
    # config/routes.rb
    post "/moonvex/webhook", to: "moonvex_webhooks#create"
    post "/orders/:id/pay", to: "orders#pay", as: :pay_order
  5. 5.Позначте замовлення оплаченим рівно один раз

    Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. Унікальний індекс на event_id: повтор викликає ActiveRecord::RecordNotUnique, транзакція відкочується, замовлення не змінюється.

    # app/models/order.rb
    class Order < ApplicationRecord
      def self.mark_paid_once!(event_id, order_id)
        transaction do
          ProcessedEvent.create!(event_id: event_id) # унікальний індекс не пропустить дубль
          order = lock.find(order_id)
          order.update!(status: "paid") unless order.status == "paid"
        end
      rescue ActiveRecord::RecordNotUnique
        # подію вже оброблено — це нормально
      end
    end
  6. 6.Перевірте інтеграцію

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

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

  • Без skip_forgery_protection Rails поверне 422 InvalidAuthenticityToken на кожен webhook.
  • Не використовуйте params для перевірки підпису — це вже розібрані дані.
  • Rails 7 без allow_other_host: true кине UnsafeRedirectError під час редиректу на pay.moonvex.org.
  • Важку обробку надсилайте в Active Job після відповіді head :ok.

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

Чи можна використовувати Faraday або HTTParty?

Так. Потрібні лише POST /v1/payments із JSON і заголовки Authorization: Bearer та Idempotency-Key. Net::HTTP у прикладі обрано, щоб не додавати гемів.

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

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

Чи є тестовий режим або sandbox?

Ні, тестової мережі немає — Moonvex працює лише в основних мережах. Підпис перевіряється кнопкою «Надіслати тест» (подія webhook.test), а весь шлях оплати — реальним платежем на невелику суму, наприклад 1 USDT у TRON.

Інші гайди

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

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

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