Приём криптоплатежей в 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.

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт