easyway
Sign inCreate free account

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

StepWhoWhat happens
1. CreateYour Java serverPOST /v1/invoices with the amount; you get back an id and a payment_url.
2. PayYour customerOpens payment_url, picks a coin and network and pays. The page follows the payment itself.
3. Confirmeasyway → your serverA 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:

DetailWhy
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-KeyIf 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 CreatedThe response body is the invoice. pay_asset, pay_amount and pay_address stay null until the customer picks a coin.
The regex helperOnly 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.

PitfallFix
Hashing a parsed bodyCheck 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 timestampsReject anything more than five minutes from your clock and keep the server on NTP.
DuplicatesDeliveries are retried until you answer 2xx; store the event id and skip repeats.
Slow handlerAnswer 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 :8080

With 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

StatuserrorWhat to do
400validation_errorA field is missing or malformed; the response lists which.
401invalid_api_keyWrong, revoked or test-vs-live key.
409request_in_progressThe same Idempotency-Key is still running: retry in a moment.
422idempotency_key_reusedSame key, different body: use a new key for a new order.
429rate_limitedOver 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.

Create free accountRead the API docsAlready have an account? Sign in
Crypto payment API in Java: invoices and webhooks | easyway