Aadhaar eSign API in Java with Spring Boot
Java's built-in java.net.http.HttpClient has no multipart helper, so this guide includes a short, correct multipart body builder. The rest is a Spring Boot service for the API calls and a REST controller that verifies webhooks with javax.crypto.Mac and a constant-time comparison.
Updated 2026-10-01
What you need
- Java 17 or newer and Spring Boot 3 (Jackson is on the classpath by default).
- A SignYu API key (starts with sk_live_). API access costs ₹999/month with a 3-day free trial; create the key under Developers in the dashboard.
- Signature credits for your signers, from ₹15 per signature (₹15 per signature on packs of 10 or more). Sending uses one credit per signer.
- A webhook endpoint added under Developers, and its signing secret.
Step 1: Configuration
Bind the key and secret from application properties backed by environment variables.
signyu:
api-key: sk_live_your_api_key # set via SIGNYU_API_KEY in production
webhook-secret: your_endpoint_secret # set via SIGNYU_WEBHOOK_SECRETStep 2: Create a document with a hand-built multipart body
Each part needs its own headers, separated by CRLF. The file part must declare Content-Type: application/pdf.
@Service
public class SignYuClient {
private static final String BASE = "https://signyu.com/api/v1";
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper json;
private final String apiKey;
public SignYuClient(ObjectMapper json, @Value("${signyu.api-key}") String apiKey) {
this.json = json;
this.apiKey = apiKey;
}
public String createDocument(Path pdf, String name) throws IOException, InterruptedException {
String boundary = "signyu-" + UUID.randomUUID();
String head = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"name\"\r\n\r\n" + name + "\r\n"
+ "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + pdf.getFileName() + "\"\r\n"
+ "Content-Type: application/pdf\r\n\r\n";
String tail = "\r\n--" + boundary + "--\r\n";
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/documents"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArrays(List.of(
head.getBytes(StandardCharsets.UTF_8),
Files.readAllBytes(pdf),
tail.getBytes(StandardCharsets.UTF_8))))
.build();
return send(req).get("documentId").asText();
}
}Step 3: Add signers, send, and map errors
JSON calls share one send() method that turns non-2xx responses into a typed exception with the API error code.
public record Signer(String name, String phone, String email) {}
public static class SignYuException extends RuntimeException {
public final int status;
public final String code;
SignYuException(int status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
}
public JsonNode addSignersAndSend(String documentId, List<Signer> signers) throws IOException, InterruptedException {
String body = json.writeValueAsString(Map.of("signers", signers));
send(jsonPost("/documents/" + documentId + "/signers", body));
return send(jsonPost("/documents/" + documentId + "/send", ""));
}
private HttpRequest jsonPost(String path, String body) {
return HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
}
private JsonNode send(HttpRequest req) throws IOException, InterruptedException {
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode node = json.readTree(res.body().isEmpty() ? "{}" : res.body());
if (res.statusCode() >= 300) {
// 402 insufficient_credits, 409 invalid_state, 401 unauthorized ...
throw new SignYuException(res.statusCode(), node.path("error").asText(), node.path("message").asText());
}
return node;
}Step 4: Download the signed PDF
Fetch downloadUrl with a request that has no Authorization header and stream it to disk.
JsonNode doc = send(HttpRequest.newBuilder(URI.create(BASE + "/documents/" + documentId))
.header("Authorization", "Bearer " + apiKey).GET().build());
if ("COMPLETED".equals(doc.path("status").asText())) {
HttpRequest download = HttpRequest.newBuilder(URI.create(doc.path("downloadUrl").asText())).GET().build();
http.send(download, HttpResponse.BodyHandlers.ofFile(Path.of("signed", documentId + ".pdf")));
}Verify webhooks
Take the body as @RequestBody String so Spring does not bind it to an object first. For application/json without a charset, Spring decodes it as UTF-8, so getBytes(UTF_8) gives back the original bytes; if you want zero doubt, take @RequestBody byte[] instead. Compare with MessageDigest.isEqual, which is constant time.
@RestController
public class SignYuWebhookController {
private final byte[] secret;
private final ObjectMapper json;
private final ApplicationEventPublisher events;
public SignYuWebhookController(@Value("${signyu.webhook-secret}") String secret,
ObjectMapper json, ApplicationEventPublisher events) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.json = json;
this.events = events;
}
@PostMapping(path = "/webhooks/signyu", consumes = "application/json")
public ResponseEntity<Void> receive(@RequestBody String body,
@RequestHeader(name = "X-SignSetu-Signature", defaultValue = "") String signature)
throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
signature.getBytes(StandardCharsets.US_ASCII))) {
return ResponseEntity.badRequest().build();
}
JsonNode event = json.readTree(body);
events.publishEvent(new SignYuEvent(event.path("event").asText(), event.path("documentId").asText()));
return ResponseEntity.ok().build();
}
}Common mistakes
- Binding the webhook to a DTO with @RequestBody MyEvent and re-serializing it never matches the signature. Verify the String or byte[] first, then parse.
- Spring Security's CSRF protection blocks the webhook POST with 403. Exclude /webhooks/signyu from CSRF and permit it without login.
- A missing CRLF or a stray space in the multipart builder gives a 400 invalid_request. Copy the boundary layout exactly.
- Do not log the full Authorization header; mask the key in HTTP client logging.
- Send is one way. A second POST to /send returns 409 invalid_state, and signers cannot be added after sending. Add every signer (up to 6) before you call send.
Frequently asked questions
Can I use RestClient or WebClient instead of HttpClient?
Yes. With RestClient or WebClient you can use MultipartBodyBuilder, which builds the parts for you; set the part's content type to application/pdf.
Does this work on Java 11?
HttpClient is available from Java 11. Replace HexFormat (Java 17) with a manual hex encoder and records with plain classes.
How do I process webhooks asynchronously in Spring?
Publish an application event, as above, and handle it in an @Async @EventListener or put it on a message queue. Return 200 quickly so delivery does not time out.
Is there an official Java SDK?
Not yet. The REST API is small, and the client class in this guide covers create, add signers, send and get.
API reference
Get your API key
Start a 3-day free trial of API access and send your first document today.