Приймання криптоплатежів у 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 секунд.