Приймання криптоплатежів у Spring Boot

Як підключити Moonvex до застосунку на Spring Boot 3: RestClient створює платіж і повертає посилання на сторінку оплати, контролер webhook отримує тіло як byte[], перевіряє підпис javax.crypto.Mac і позначає замовлення в транзакції.

Покроково

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

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

    # application.properties — значення зі змінних оточення
    moonvex.secret-key=${MOONVEX_SECRET_KEY}
    moonvex.webhook-secret=${MOONVEX_WEBHOOK_SECRET}
  2. 2.Створіть платіж через RestClient

    RestClient (Spring Framework 6.1+) з базовою адресою і заголовком Authorization створюється один раз. Сума — BigDecimal.toPlainString(), без double. Контролер відповідає 303 з Location на paymentUrl.

    // MoonvexClient.java
    @Service
    public class MoonvexClient {
        private final RestClient http;
    
        public MoonvexClient(@Value("${moonvex.secret-key}") String secretKey) {
            this.http = RestClient.builder()
                    .baseUrl("https://api.moonvex.org")
                    .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + secretKey)
                    .build();
        }
    
        public JsonNode createPayment(long orderId, BigDecimal amount, int attempt) {
            return http.post()
                    .uri("/v1/payments")
                    .header("Idempotency-Key", "order-" + orderId + "-" + attempt)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(Map.of(
                            "orderId", String.valueOf(orderId),
                            "amount", amount.toPlainString(), // BigDecimal → "25.00", не double
                            "currency", "USDT",
                            "network", "TRON"))
                    .retrieve()
                    .body(JsonNode.class);
        }
    }
    
    // PayController.java
    @RestController
    public class PayController {
        private final MoonvexClient moonvex;
        private final OrderRepository orders;
    
        public PayController(MoonvexClient moonvex, OrderRepository orders) {
            this.moonvex = moonvex;
            this.orders = orders;
        }
    
        @PostMapping("/orders/{id}/pay")
        public ResponseEntity<Void> pay(@PathVariable long id) {
            Order order = orders.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
            JsonNode payment = moonvex.createPayment(order.getId(), order.getTotal(), 1);
            return ResponseEntity.status(HttpStatus.SEE_OTHER)
                    .location(URI.create(payment.get("paymentUrl").asText()))
                    .build();
        }
    }
  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 — підходить будь-який. Параметр @RequestBody byte[] отримує тіло без участі Jackson — саме ці байти підписано. Підписи порівнюються MessageDigest.isEqual, hex розбирається HexFormat (Java 17+).

    // MoonvexWebhookController.java
    @RestController
    public class MoonvexWebhookController {
        private final ObjectMapper json;
        private final OrderService orders;
        private final byte[] secret;
    
        public MoonvexWebhookController(ObjectMapper json, OrderService orders,
                                        @Value("${moonvex.webhook-secret}") String secret) {
            this.json = json;
            this.orders = orders;
            this.secret = secret.getBytes(StandardCharsets.UTF_8);
        }
    
        // byte[] — тіло як є, без перетворення Jackson
        @PostMapping("/moonvex/webhook")
        public ResponseEntity<String> webhook(@RequestBody byte[] raw,
                                              @RequestHeader(name = "Moonvex-Signature", required = false) String signature) throws Exception {
            if (!verify(raw, signature)) {
                return ResponseEntity.badRequest().body("bad signature");
            }
            JsonNode event = json.readTree(raw);
            String type = event.path("type").asText();
            if (type.equals("payment.confirmed") || type.equals("payment.overpaid")) {
                orders.markPaidOnce(event.path("id").asText(), event.path("data").path("orderId").asText());
            }
            return ResponseEntity.ok("ok");
        }
    
        private boolean verify(byte[] raw, String header) throws Exception {
            if (header == null) return false;
            String ts = null;
            List<String> sigs = new ArrayList<>();
            for (String part : header.split(",")) {
                String[] kv = part.split("=", 2);
                if (kv.length != 2) continue;
                if (kv[0].equals("t")) ts = kv[1];
                else if (kv[0].equals("v1")) sigs.add(kv[1]);
            }
            if (ts == null || !ts.matches("\\d+")) return false;
            if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(ts)) > 300) return false;
    
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            mac.update((ts + ".").getBytes(StandardCharsets.UTF_8));
            byte[] expected = mac.doFinal(raw);
            for (String s : sigs) {
                try {
                    // MessageDigest.isEqual — порівняння за сталий час
                    if (MessageDigest.isEqual(expected, HexFormat.of().parseHex(s))) return true;
                } catch (IllegalArgumentException ignored) {
                    // не hex — пропускаємо
                }
            }
            return false;
        }
    }
  5. 5.Позначте замовлення оплаченим рівно один раз

    Під час повторної доставки id події той самий, тому запишіть його в таблицю з унікальним ключем в одній транзакції з оновленням замовлення: дубль нічого не змінить. Видавайте товар за payment.confirmed і payment.overpaid, а не за payment.detected. @Transactional і INSERT … ON CONFLICT DO NOTHING: jdbc.update поверне 0 під час повторної доставки.

    // OrderService.java
    // CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
    @Service
    public class OrderService {
        private final JdbcTemplate jdbc;
    
        public OrderService(JdbcTemplate jdbc) {
            this.jdbc = jdbc;
        }
    
        @Transactional
        public void markPaidOnce(String eventId, String orderId) {
            int fresh = jdbc.update("INSERT INTO processed_events (event_id) VALUES (?) ON CONFLICT DO NOTHING", eventId);
            if (fresh == 0) return; // повторна доставка
            jdbc.update("UPDATE orders SET status = 'paid' WHERE id = ? AND status <> 'paid'", Long.parseLong(orderId));
        }
    }
  6. 6.Перевірте інтеграцію

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

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

  • Якщо підключено Spring Security, виключіть /moonvex/webhook із CSRF (csrf.ignoringRequestMatchers) і дозвольте його без автентифікації (permitAll).
  • Не приймайте тіло webhook як DTO або Map: після Jackson байти інші, підпис не збіжиться.
  • Гроші — лише BigDecimal; у JSON надсилайте toPlainString(), щоб не отримати 2.5E+1.
  • Важку обробку виносьте в @Async або чергу: відповідь webhook має піти за 10 секунд.

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

Чи підійде WebClient або OkHttp замість RestClient?

Так, протокол звичайний: POST /v1/payments із JSON, заголовками Authorization: Bearer та Idempotency-Key. RestClient у прикладі обрано як синхронний клієнт із самого Spring.

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

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

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

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

Інші гайди

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

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

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