Skip to content

Java

Compatibility

JDK 11+ (native HttpClient). No third-party dependencies.

Client Implementation

java
package com.example.coinepay;

import java.net.URI;
import java.net.http.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.UUID;

public class coinepayClient {
    private static final String APP_ID = "cp_a1b2c3d4...";
    private static final String SECRET = "f1e2d3c4...";
    private static final String BASE = "https://api.coinepay.net";

    private static final HttpClient HTTP = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .build();

    public static String call(String path, String body, String idempotencyKey) throws Exception {
        if (body == null) body = "{}";
        String ts = String.valueOf(System.currentTimeMillis() / 1000);
        byte[] nonceBytes = new byte[16];
        new SecureRandom().nextBytes(nonceBytes);
        String nonce = bytesToHex(nonceBytes);
        String bodyHash = sha256Hex(body.getBytes("UTF-8"));
        String signInput = "POST\n" + path + "\n\n" + ts + "\n" + nonce + "\n" + bodyHash;
        String sig = hmacHex(SECRET, signInput);

        HttpRequest.Builder b = HttpRequest.newBuilder()
            .uri(URI.create(BASE + path))
            .timeout(Duration.ofSeconds(30))
            .header("X-App-Id", APP_ID)
            .header("X-Timestamp", ts)
            .header("X-Nonce", nonce)
            .header("X-Signature", sig)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body));
        if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);

        HttpResponse<String> resp = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
        return resp.body();
    }

    /** Webhook signature verification */
    public static boolean verifyWebhook(String sigHeader, String tsHeader,
                                        byte[] rawBody, String webhookSecret) throws Exception {
        if (sigHeader == null || !sigHeader.startsWith("v1,")) return false;
        String sig = sigHeader.substring(3);

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(webhookSecret.getBytes("UTF-8"), "HmacSHA256"));
        mac.update((tsHeader + ".").getBytes("UTF-8"));
        mac.update(rawBody);
        String expected = bytesToHex(mac.doFinal());

        return constantTimeEquals(sig, expected);
    }

    private static String hmacHex(String key, String data) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"));
        return bytesToHex(mac.doFinal(data.getBytes("UTF-8")));
    }

    private static String sha256Hex(byte[] b) throws Exception {
        return bytesToHex(MessageDigest.getInstance("SHA-256").digest(b));
    }

    private static String bytesToHex(byte[] b) {
        StringBuilder sb = new StringBuilder(b.length * 2);
        for (byte x : b) sb.append(String.format("%02x", x));
        return sb.toString();
    }

    private static boolean constantTimeEquals(String a, String b) {
        if (a.length() != b.length()) return false;
        int diff = 0;
        for (int i = 0; i < a.length(); i++) diff |= a.charAt(i) ^ b.charAt(i);
        return diff == 0;
    }
}

Usage

java
public class Demo {
    public static void main(String[] args) throws Exception {
        // 1. List card headers
        String resp = coinepayClient.call(
            "/api/v1/openapi/card_headers/list",
            "{\"page\":1,\"page_size\":20}",
            null);
        System.out.println(resp);

        // 2. Apply virtual card
        String idempotencyKey = UUID.randomUUID().toString();
        String body = """
            {"header_id":"hdr_5","package_id":"pkg_12",
             "first_name":"John","last_name":"Doe"}
            """;
        String applyResp = coinepayClient.call(
            "/api/v1/openapi/card/apply", body, idempotencyKey);
        System.out.println(applyResp);
    }
}

Webhook Receiver (Spring Boot)

java
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import javax.servlet.http.HttpServletRequest;

@RestController
public class WebhookController {
    private static final String WEBHOOK_SECRET = System.getenv("COINEPAY_WEBHOOK_SECRET");

    @PostMapping(value = "/webhook", consumes = "application/json")
    public ResponseEntity<String> handleWebhook(
            @RequestBody byte[] rawBody,
            @RequestHeader(value = "Webhook-Signature", required = false) String sig,
            @RequestHeader(value = "Webhook-Timestamp", required = false) String ts,
            @RequestHeader(value = "Webhook-Id", required = false) String eventId,
            @RequestHeader(value = "Webhook-Type", required = false) String eventType
    ) throws Exception {
        if (!coinepayClient.verifyWebhook(sig, ts, rawBody, WEBHOOK_SECRET)) {
            return ResponseEntity.status(401).body("invalid signature");
        }
        long now = System.currentTimeMillis() / 1000;
        if (Math.abs(now - Long.parseLong(ts)) > 300) {
            return ResponseEntity.status(401).body("timestamp expired");
        }
        if (alreadyProcessed(eventId)) return ResponseEntity.ok("");

        String payload = new String(rawBody, "UTF-8");
        enqueue(eventType, payload);
        markProcessed(eventId);

        return ResponseEntity.ok("");
    }
}

Spring Boot raw body

Spring deserializes @RequestBody by default. Use byte[] to receive raw bytes — otherwise the body is parsed and re-serialized, breaking the signature.

Notes

  • secretSpec uses getBytes("UTF-8") — never use platform default encoding
  • Java string \n literals in source are 0x0A — no special handling needed
  • Use MessageDigest.isEqual or your own constant-time compare; String.equals is not constant-time

Released under MIT-equivalent terms.