A crypto payment API in Java: create an invoice and verify the webhook
easyway has no Java SDK, and you don't need one. The API is plain HTTPS and JSON, so the JDK already has everything: java.net.http.HttpClient to create an invoice, and javax.crypto.Mac to check the HMAC on the webhook that tells you it was paid. This post shows both as two small files with no dependencies, which we compiled and ran with Java 21 (Java 15 or later is enough for the text block).
The flow in three calls
| Step | Who | What happens |
|---|---|---|
| 1. Create | Your Java server | POST /v1/invoices with the amount; you get back an id and a payment_url. |
| 2. Pay | Your customer | Opens payment_url, picks a coin and network and pays. The page follows the payment itself. |
| 3. Confirm | easyway → your server | A signed invoice.paid webhook arrives. That, not the customer coming back, is the proof. |
The browser never talks to the API: your secret key stays on the server, and the customer only sees the hosted checkout. Create the key in Dashboard → Developers; an sk_test_… key only ever sees sandbox data, so you can build the whole integration before any real money moves.
1. Create the invoice
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CreateInvoice {
static final String API = System.getenv().getOrDefault("EASYWAY_API", "https://api.easyway.cash");
public static void main(String[] args) throws Exception {
String orderId = "2048";
// Amounts are decimal strings, never doubles.
String body = """
{"amount":"149.00","currency":"USD","order_id":"%s",
"description":"Order #%s","success_url":"https://shop.example.com/thanks"}
""".formatted(orderId, orderId);
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/v1/invoices"))
.header("Authorization", "Bearer " + System.getenv("EASYWAY_API_KEY"))
.header("Idempotency-Key", "order-" + orderId) // a retry returns the same invoice
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new IllegalStateException("easyway " + res.statusCode() + ": " + field(res.body(), "error"));
}
// Store the id with the order, then redirect the customer to payment_url.
System.out.println("invoice " + field(res.body(), "id") + " -> " + field(res.body(), "payment_url"));
}
// Reads one top-level string field; use Jackson or Gson in a real project.
static String field(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
}
A few details that matter in Java in particular:
| Detail | Why |
|---|---|
Amounts as strings ("149.00") | The API takes decimal strings so nothing is lost to floating point. In Java keep money in BigDecimal and send toPlainString(), never a double. |
| Idempotency-Key | If the call times out and you retry with the same key and body, you get the same invoice back instead of a second one. 8–128 characters: letters, digits, _ - : . |
| 201 Created | The response body is the invoice. pay_asset, pay_amount and pay_address stay null until the customer picks a coin. |
| The regex helper | Only there to keep the file dependency-free. In a real project map the response with Jackson or Gson. |
To bill in a coin instead of USD, set currency to an asset id such as "USDT_TRC20"; to limit the coins on the checkout, add allowed_assets. Every field is listed in the API docs, and the OpenAPI document can generate typed Java models if you prefer that over hand-written ones.
2. Send the customer to payment_url
In Spring MVC that is return "redirect:" + paymentUrl;, in a servlet response.sendRedirect(paymentUrl). The checkout works on phones and desktops, shows a QR code with the address and amount, and asks for the rest if the customer sends too little. Save the invoice id next to your order so the webhook can be matched to it.
3. Verify the webhook
Every delivery carries X-Webhook-Signature: t=…,v1=…, where v1 is the lower-case hex HMAC-SHA256 of t + "." + raw body with the endpoint's signing secret. The sample uses the JDK's built-in HttpServer so it runs as is:
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class WebhookServer {
static final String SECRET = System.getenv("EASYWAY_WEBHOOK_SECRET"); // Dashboard → Developers → Webhooks
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/webhooks/easyway", WebhookServer::handle);
server.start();
}
static void handle(HttpExchange ex) throws java.io.IOException {
byte[] raw = ex.getRequestBody().readAllBytes(); // the RAW body is what was signed
String header = ex.getRequestHeaders().getFirst("X-Webhook-Signature");
if (!verify(header, raw, System.currentTimeMillis() / 1000)) {
reply(ex, 400, "bad signature");
return;
}
String event = new String(raw, StandardCharsets.UTF_8);
// Parse with your JSON library; store the event id and skip repeats (deliveries are retried).
// if type is "invoice.paid": mark data.invoice.order_id paid with data.invoice.net_amount
reply(ex, 200, "ok"); // answer fast, do the heavy work afterwards
}
static boolean verify(String header, byte[] raw, long now) {
if (header == null) return false;
String t = null, v1 = null;
for (String kv : header.split(",")) {
String[] p = kv.split("=", 2);
if (p.length == 2 && p[0].equals("t")) t = p[1];
if (p.length == 2 && p[0].equals("v1")) v1 = p[1];
}
if (t == null || v1 == null) return false;
try {
if (Math.abs(now - Long.parseLong(t)) > 300) return false; // older than 5 minutes
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(raw));
return MessageDigest.isEqual( // constant-time compare
expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
return false;
}
}
static void reply(HttpExchange ex, int status, String text) throws java.io.IOException {
byte[] b = text.getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(status, b.length);
ex.getResponseBody().write(b);
ex.close();
}
}
We tested it against headers produced by easyway's own signing function: a genuine delivery got 200; the same delivery with one changed amount, with a timestamp 400 seconds old, or with no header got 400.
| Pitfall | Fix |
|---|---|
| Hashing a parsed body | Check the signature on the exact bytes. In Spring take @RequestBody byte[] (or String), never a mapped object, then parse. |
Comparing with equals() | Use MessageDigest.isEqual, which takes the same time however many characters match. |
| Old timestamps | Reject anything more than five minutes from your clock and keep the server on NTP. |
| Duplicates | Deliveries are retried until you answer 2xx; store the event id and skip repeats. |
| Slow handler | Answer within 10 seconds and do the work afterwards, for example on an ExecutorService. |
The same recipe in Node.js, PHP and Python, plus a way to sign a test request with openssl, is in the webhook signature guide.
Run it
javac CreateInvoice.java WebhookServer.java
EASYWAY_API_KEY=sk_test_… java CreateInvoice
EASYWAY_WEBHOOK_SECRET=whsec_… java WebhookServer # listens on :8080With a test key, create an invoice, open its payment_url and press Simulate payment to receive a real invoice.paid delivery; the sandbox guide has the details. Your webhook URL must be reachable from the internet, so expose port 8080 with a tunnel while testing.
Errors to handle
| Status | error | What to do |
|---|---|---|
| 400 | validation_error | A field is missing or malformed; the response lists which. |
| 401 | invalid_api_key | Wrong, revoked or test-vs-live key. |
| 409 | request_in_progress | The same Idempotency-Key is still running: retry in a moment. |
| 422 | idempotency_key_reused | Same key, different body: use a new key for a new order. |
| 429 | rate_limited | Over 300 requests per minute on the key: back off and retry. |
Match on the error code, not the message. For a plain explanation of what happens between the checkout and your webhook, see how crypto payment gateways work; for the fee, a flat 0.5% of each confirmed payment, see pricing.