Приём криптоплатежей в Spring Boot
Как подключить Moonvex к приложению на Spring Boot 3: RestClient создаёт платёж и возвращает ссылку на страницу оплаты, контроллер webhook получает тело как byte[], проверяет подпись javax.crypto.Mac и отмечает заказ в транзакции.
Пошагово
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.Создайте платёж через 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.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id.
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.Отметьте заказ оплаченным ровно один раз
При повторной доставке 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.Проверьте интеграцию
Тестовой сети у 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 секунд.