Java Signing And Verification
Runtime
Java 17+; JDK standard library, no Lombok or third-party crypto dependency.
Shared Contract
These examples implement the default merchant API and default platform callback, not separately agreed custom signature formats. Both languages of the documentation include the same executable source files.
API request with a body, signed with the merchant private key:
path + lowercaseHex(SHA512(rawBody)) + Timestamp
API request without a body, signed with the merchant private key:
path + Timestamp
Default asynchronous callback, verified with the platform public key:
lowercaseHex(SHA512(rawBody)) + Timestamp
Synchronous API response envelope, verified with the platform public key:
lowercaseHex(SHA512(platform-serialized data bytes)) + timestamp
- Use RSA PKCS#1 v1.5 with SHA-512 and standard Base64 signatures, not RSA-PSS or RSA encryption.
- Timestamps are Unix seconds. Verification uses the received timestamp, not a newly generated value. Header names are case-insensitive; Base64 signature values are case-sensitive.
- The API
pathexcludes scheme, host and query string. Callback signing content has no URL, path or separators. - Serialize JSON once. Sign and transmit exactly the same UTF-8 bytes. No sorting is required, but field order, whitespace, escaping, newlines and numeric representation must match the transmitted bytes.
- Use
null/nil/Nonefor no request body. A present zero-byte body is different. Callback verification always hashes the complete received body. - Supported keys: unencrypted PKCS#1 / PKCS#8 private keys and X.509 SPKI / PKCS#1 public keys, as PEM or plain Base64, including escaped newlines. Certificates, encrypted private keys, SSH keys and non-RSA keys are not supported by these samples.
- Sign requests with your merchant private key. Verify with the configured platform public key, not your merchant public key or a key supplied in an incoming request. Never share private keys with support; rotate exposed keys.
Failure Handling
Signing failures throw an exception or return an error. Stop the request; never send an empty signature. Verification returns normally on success and throws/returns an error on failure, logging [payment-verify] FAILED. Never ignore verification failures or continue processing financial effects.
Logs contain the stage, byte lengths and diagnostic reason, not payloads, complete signatures or keys. Keep diagnostics internal, disable HTTP body logging and rate-limit failed requests and logs.
Keep HTTPS certificate and hostname verification enabled. A timeout does not prove order creation failed. Do not automatically resubmit an order; query by merchant order number or reconcile manually.
Callback Integration
Limit body size, read raw bytes and the Timestamp / Signature headers, then verify before parsing JSON. Validate order ownership, amount, currency and allowed state transitions. Apply changes transactionally and idempotently. Return the required SUCCESS acknowledgement only after durable processing. Repeated deliveries must not apply financial effects twice.
Cryptographic verification alone does not prevent replay. Choose timestamp tolerance compatible with platform redelivery rules; implement durable idempotency, replay controls and rate limits separately. An overly short freshness window can reject legitimate retries.
Synchronous Responses Are Not Callbacks
For API response envelopes, read timestamp and sign from JSON, and verify the platform-serialized data bytes, not the entire response. For callbacks, read headers and verify the entire HTTP body.
Do not parse and re-serialize data: large integers, 1.00, escaping and field ordering may change. Use a parser that preserves the original JSON value text (for example Go json.RawMessage or C# JsonElement.GetRawText()), then pass its UTF-8 bytes to verifyPlatform / VerifyPlatform / verify_platform. Preserve JSON quotes/escapes for string values and the literal null for null values. Do not add whitespace. Missing signatures, malformed fields and failed verification are not trusted business results.
API Paths
Use the same request helper for the paths below. Refer to the relevant currency documentation for payload fields.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/merchant/pay | Collection order |
| POST | /v1/merchant/pay/order/info | Collection query |
| POST | /v1/merchant/payout | Payout order |
| POST | /v1/merchant/payout/order/info | Payout query |
| GET | /v1/merchant/account | Balance query, no body |
Signing And Verification Source
SignUtil.java
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.interfaces.RSAKey;
import java.security.spec.*;
import java.util.Base64;
import java.util.HexFormat;
import java.util.regex.Pattern;
public final class SignUtil {
private SignUtil() {}
private static final byte[] RSA_ALGORITHM = HexFormat.of().parseHex("300d06092a864886f70d0101010500");
private static String timestamp(String text) {
try {
if (text == null || !text.matches("[1-9][0-9]{0,18}") || Long.parseLong(text) <= 0) throw new IllegalArgumentException();
return text;
} catch (IllegalArgumentException e) { throw new IllegalArgumentException("invalid Timestamp: use Unix seconds"); }
}
private static byte[] base64(String text, String label) {
try {
byte[] bytes = Base64.getDecoder().decode(text);
if (bytes.length == 0 || !Base64.getEncoder().encodeToString(bytes).equals(text)) throw new IllegalArgumentException();
return bytes;
} catch (IllegalArgumentException | NullPointerException e) { throw new IllegalArgumentException(label + ": invalid Base64"); }
}
private static byte[] keyDER(String input, boolean privateKey) {
if (input == null || input.length() > 16384) throw new IllegalArgumentException("key: missing or too large");
String text = input.replace("\\r\\n", "\n").replace("\\n", "\n").trim();
if (text.startsWith("-----BEGIN")) {
var m = Pattern.compile("\\A-----BEGIN ([A-Z ]+)-----\\s*([A-Za-z0-9+/=\\s]+)\\s*-----END \\1-----\\z").matcher(text);
if (!m.matches()) throw new IllegalArgumentException("key: expected one complete PEM block");
String label = m.group(1);
boolean allowed = privateKey ? label.equals("PRIVATE KEY") || label.equals("RSA PRIVATE KEY")
: label.equals("PUBLIC KEY") || label.equals("RSA PUBLIC KEY");
if (!allowed) throw new IllegalArgumentException("key: expected unencrypted RSA PEM of the correct kind");
text = m.group(2);
}
return base64(text.replaceAll("\\s", ""), "key");
}
// Container wrapping only; JCA performs ASN.1 parsing and RSA key import.
private static byte[] der(int tag, byte[]... values) {
var data = new ByteArrayOutputStream();
for (byte[] value : values) data.writeBytes(value);
byte[] bytes = data.toByteArray();
var out = new ByteArrayOutputStream();
out.write(tag);
if (bytes.length < 128) out.write(bytes.length);
else {
int count = (32 - Integer.numberOfLeadingZeros(bytes.length) + 7) / 8;
out.write(128 | count);
for (int i = count - 1; i >= 0; i--) out.write(bytes.length >>> (i * 8));
}
out.writeBytes(bytes);
return out.toByteArray();
}
private static Key key(String input, boolean privateKey) throws GeneralSecurityException {
byte[] raw = keyDER(input, privateKey);
byte[] wrapped = privateKey
? der(48, new byte[]{2, 1, 0}, RSA_ALGORITHM, der(4, raw))
: der(48, RSA_ALGORITHM, der(3, new byte[]{0}, raw));
for (byte[] bytes : new byte[][]{raw, wrapped}) {
try {
var factory = KeyFactory.getInstance("RSA");
return privateKey ? factory.generatePrivate(new PKCS8EncodedKeySpec(bytes))
: factory.generatePublic(new X509EncodedKeySpec(bytes));
} catch (InvalidKeySpecException e) { /* Try the other documented container. */ }
}
throw new InvalidKeySpecException("key: invalid/truncated, encrypted or non-RSA key; DER bytes=" + raw.length);
}
private static String hash(byte[] body) throws GeneralSecurityException {
if (body == null) throw new IllegalArgumentException("body: expected raw bytes");
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-512").digest(body));
}
private static void log(String operation, byte[] body, Exception e) {
String reason = e instanceof IllegalArgumentException || e instanceof InvalidKeySpecException
? e.getMessage() : "RSA operation failed (" + e.getClass().getSimpleName() + ")";
System.err.printf("[payment-%s] FAILED body_bytes=%d error=%s%n", operation, body == null ? 0 : body.length, reason);
}
// null means no request body; new byte[0] means a present, empty body.
public static String signRequest(String path, byte[] body, String timestamp, String merchantPrivateKey) throws GeneralSecurityException {
try {
if (path == null || !path.startsWith("/")) throw new IllegalArgumentException("path: expected API path");
String origin = path + (body == null ? "" : hash(body)) + timestamp(timestamp);
var signer = Signature.getInstance("SHA512withRSA");
signer.initSign((PrivateKey) key(merchantPrivateKey, true));
signer.update(origin.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signer.sign());
} catch (GeneralSecurityException | RuntimeException e) { log("sign", body, e); throw e; }
}
// Exact callback bytes, or exact signed data bytes for API envelopes. NO path.
public static void verifyPlatform(byte[] body, String timestamp, String signature, String platformPublicKey) throws GeneralSecurityException {
try {
String origin = hash(body) + timestamp(timestamp);
PublicKey publicKey = (PublicKey) key(platformPublicKey, false);
byte[] sig = base64(signature, "Signature");
int expected = (((RSAKey) publicKey).getModulus().bitLength() + 7) / 8;
if (sig.length != expected) throw new IllegalArgumentException("Signature length: got " + sig.length + ", expected " + expected + " bytes");
var verifier = Signature.getInstance("SHA512withRSA");
verifier.initVerify(publicKey);
verifier.update(origin.getBytes(StandardCharsets.UTF_8));
if (!verifier.verify(sig)) throw new IllegalArgumentException("RSA SHA512 verification failed: check platform key, raw bytes, timestamp and formula");
} catch (GeneralSecurityException | RuntimeException e) { log("verify", body, e); throw e; }
}
}
HTTP Helper
HttpUtil.java
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.time.Instant;
import java.security.GeneralSecurityException;
public final class HttpUtil {
private static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NEVER).build();
public static byte[] send(String baseUrl, String path, String merchantCode, byte[] rawBody, String merchantPrivateKey)
throws GeneralSecurityException, IOException, InterruptedException {
if (!baseUrl.startsWith("https://")) throw new IllegalArgumentException("HTTPS is required");
String timestamp = Long.toString(Instant.now().getEpochSecond());
String signature = SignUtil.signRequest(path, rawBody, timestamp, merchantPrivateKey);
var builder = HttpRequest.newBuilder(URI.create(baseUrl + path)).timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json").header("Merchant-Code", merchantCode)
.header("Timestamp", timestamp).header("Signature", signature);
if (rawBody == null) builder.GET();
else builder.POST(HttpRequest.BodyPublishers.ofByteArray(rawBody));
var response = CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
try (var stream = response.body()) {
if (response.statusCode() < 200 || response.statusCode() >= 300)
throw new IOException("HTTP " + response.statusCode() + "; reconcile before retrying");
byte[] result = stream.readNBytes((8 << 20) + 1);
if (result.length > (8 << 20)) throw new IOException("Response exceeds 8 MiB");
return result; // Preserve raw response for envelope verification.
}
}
}
Integration Examples
The following snippets run inside your application; payload, trusted configuration and transactional business handlers must be supplied by your integration. They do not automatically submit real orders.
// Java 17+, imports java.nio.charset.StandardCharsets.
// Serialize payload once with your JSON library, without changing the bytes after signing.
byte[] body = serializedJson.getBytes(StandardCharsets.UTF_8);
byte[] responseBytes = HttpUtil.send(baseUrl, "/v1/merchant/pay", merchantCode, body, merchantPrivateKey);
// GET: HttpUtil.send(baseUrl, "/v1/merchant/account", merchantCode, null, merchantPrivateKey);
// Verify the signed response data before using it. Do not treat HTTP 200 as payment success.
// Inside your servlet handler. Also enforce an ingress/request body limit.
byte[] rawBody = request.getInputStream().readNBytes((1 << 20) + 1);
if (rawBody.length > (1 << 20)) { response.sendError(413); return; }
try {
SignUtil.verifyPlatform(rawBody, request.getHeader("Timestamp"),
request.getHeader("Signature"), configuredPlatformPublicKey);
} catch (java.security.GeneralSecurityException | IllegalArgumentException e) {
response.sendError(401, "invalid signature"); return;
}
// Only now parse rawBody, validate order/amount/currency and commit an idempotent update.
// Write SUCCESS only after durable processing.
