August 12, 2026 · Tutorial
·12 min readAdd E-Signatures to Java Spring Boot Applications via REST API (2026)
Enterprise document signing in Java has historically meant bloated SDK jars, complex DocuSign SOAP/OAuth token refreshes, and brittle XML templates. In this guide, learn how to build an enterprise-grade e-signature workflow using modern Spring Boot 3.3+, Java 21 records, the synchronous RestClient, timing-safe HMAC-SHA256 webhook validation, and Hibernate JPA audit trail persistence.
Founder, Signbee & B2bee Ltd
Enterprise e-signing in Java does not require 45MB vendor SDKs or multi-step OAuth dance ceremonies. By leveraging the unified REST architecture of Signbee alongside modern Spring Framework 6.x features:
- • Modern HTTP Client: We use Spring Boot 3's fluent
RestClientwith connection pooling and Java 21 Virtual Threads (spring.threads.virtual.enabled=true). - • Zero-Boilerplate Models: We model requests, responses, and events with immutable Java 21
recordtypes and Jackson annotations. - • Constant-Time Security: We enforce webhook authenticity with
javax.crypto.MacandMessageDigest.isEqual()to block timing side-channel attacks. - • Regulatory Compliance: We persist full tamper-evident lifecycle events into PostgreSQL/MySQL using Spring Data JPA.
Why Java Enterprise Teams Are Moving Away from Heavyweight SDKs
For more than a decade, enterprise Java teams integrating e-signatures were forced down a monolithic path. Traditional vendors like DocuSign and Adobe Acrobat Sign provide massive Java SDKs loaded with hundreds of transitive dependencies, outdated Apache HttpClient versions, and convoluted class hierarchies.
In a typical DocuSign Java integration, sending a single Non-Disclosure Agreement (NDA) or Master Services Agreement (MSA) requires:
- Setting up an RSA 2048-bit private key pair in PKCS#8 format to request an OAuth 2.0 JWT Grant.
- Instituting an in-memory or Redis-backed token cache with proactive refresh intervals before 3600-second expiration.
- Constructing deeply nested Java objects:
EnvelopeDefinition,TemplateRole,Signer,SignHeretabs,Documentbyte arrays, andCustomFields. - Managing dependency vulnerability scanners (like Snyk or Dependabot) constantly alerting on CVEs inside legacy SOAP or unmaintained Apache CXF dependencies bundled in vendor SDK jars.
In contrast, Signbee operates on a clean, single-endpoint REST principle. You send structured Markdown containing your document text and variables; the API generates a cryptographically sealed, pixel-perfect PDF, handles signer delivery, collects legal signatures, and streams verifiable webhooks back to your Spring Boot microservices.
End-to-End Enterprise Spring Boot Workflow
Spring Boot service builds Markdown contract & calls Signbee REST API via RestClient.
Signbee handles mobile/desktop signing ceremony, generating SHA-256 tamper-evident digital certificate.
Signbee posts signed event. Spring MVC Controller validates HMAC-SHA256 signature in constant time.
Spring Data JPA persists contract status, certificate hash, signer IP, and signed PDF download link.
Prerequisites & Technology Stack
This tutorial is tested against the modern LTS enterprise Java ecosystem:
javax.crypto.Mac and MessageDigest (zero extra security libs).Step 1: Project Setup & Configuration
First, ensure your pom.xml (or build.gradle) includes the required starters. Notice that we do not include any vendor e-signature SDK jars:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>signbee-spring-boot-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>signbee-spring-boot-demo</name>
<description>Enterprise E-Signature Integration with Spring Boot 3 and Java 21</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Database Driver (PostgreSQL or H2 for testing) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>Next, configure your API credentials and connection parameters in src/main/resources/application.yml. In production, provide the API key and webhook secret via environment variables or secret managers like AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault:
server:
port: 8080
spring:
application:
name: enterprise-contract-service
threads:
virtual:
enabled: true # Enable Java 21 Project Loom Virtual Threads
datasource:
url: jdbc:postgresql://localhost:5432/contracts_db
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
# Signbee API & Webhook Configuration
signbee:
api:
base-url: "https://signb.ee/api/v1"
key: ${SIGNBEE_API_KEY:sb_live_your_api_key_here}
connect-timeout-ms: 5000
read-timeout-ms: 15000
webhook:
secret: ${SIGNBEE_WEBHOOK_SECRET:whsec_your_webhook_signing_secret_here}
callback-url: "https://api.yourdomain.com/api/v1/webhooks/signbee"Bind these settings to a type-safe Spring @ConfigurationProperties record:
package com.example.signbee.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "signbee")
public record SignbeeProperties(
Api api,
Webhook webhook
) {
public record Api(
String baseUrl,
String key,
Duration connectTimeout,
Duration readTimeout
) {
public Api {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = "https://signb.ee/api/v1";
}
}
}
public record Webhook(
String secret,
String callbackUrl
) {}
}Step 2: Define Java 21 Immutable Record DTOs
Java 21 records provide concise, immutable data carriers with automatic constructors, getters, equals(), hashCode(), and toString() implementations. Combined with Jackson annotations, they serve as the cleanest data transfer objects for REST payloads:
package com.example.signbee.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
public record SendDocumentRequest(
@NotBlank(message = "Document markdown content cannot be blank")
@JsonProperty("markdown")
String markdown,
@NotBlank(message = "Recipient name is required")
@JsonProperty("recipient_name")
String recipientName,
@NotBlank(message = "Recipient email is required")
@Email(message = "Recipient email must be valid")
@JsonProperty("recipient_email")
String recipientEmail,
@JsonProperty("webhook_url")
String webhookUrl,
@JsonProperty("title")
String title,
@JsonProperty("metadata")
Map<String, String> metadata
) {
// Factory method for standard employment / sales agreements
public static SendDocumentRequest of(String markdown, String name, String email, String webhookUrl) {
return new SendDocumentRequest(markdown, name, email, webhookUrl, null, Map.of());
}
}package com.example.signbee.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Map;
public record SendDocumentResponse(
@JsonProperty("document_id")
String documentId,
@JsonProperty("signing_url")
String signingUrl,
@JsonProperty("status")
String status,
@JsonProperty("created_at")
Instant createdAt,
@JsonProperty("expires_at")
Instant expiresAt
) {}
// Webhook payload records
public record WebhookEvent(
@JsonProperty("event")
String event, // e.g., "document.signed", "document.viewed", "document.declined"
@JsonProperty("timestamp")
Instant timestamp,
@JsonProperty("data")
WebhookData data
) {}
public record WebhookData(
@JsonProperty("document_id")
String documentId,
@JsonProperty("signer_name")
String signerName,
@JsonProperty("signer_email")
String signerEmail,
@JsonProperty("signed_pdf_url")
String signedPdfUrl,
@JsonProperty("certificate_hash")
String certificateHash,
@JsonProperty("ip_address")
String ipAddress,
@JsonProperty("user_agent")
String userAgent,
@JsonProperty("metadata")
Map<String, String> metadata
) {}Step 3: Build the Spring Boot 3 `RestClient` Service
Spring Framework 6.1 introduced RestClient as the modern synchronous alternative to RestTemplate. It provides a functional, fluent API with intuitive response extraction and non-reactive virtual thread efficiency.
First, configure the RestClient bean with Bearer authentication and custom error handling:
package com.example.signbee.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration
@EnableConfigurationProperties(SignbeeProperties.class)
public class SignbeeClientConfig {
@Bean
public RestClient signbeeRestClient(SignbeeProperties properties) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(properties.api().connectTimeout() != null
? properties.api().connectTimeout()
: java.time.Duration.ofSeconds(5));
requestFactory.setReadTimeout(properties.api().readTimeout() != null
? properties.api().readTimeout()
: java.time.Duration.ofSeconds(15));
return RestClient.builder()
.baseUrl(properties.api().baseUrl())
.requestFactory(requestFactory)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.api().key())
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("User-Agent", "Signbee-Java-SpringBoot/1.0")
.build();
}
}Now, implement SignbeeService.java to handle document dispatch, status queries, and signed PDF retrieval:
package com.example.signbee.service;
import com.example.signbee.config.SignbeeProperties;
import com.example.signbee.dto.SendDocumentRequest;
import com.example.signbee.dto.SendDocumentResponse;
import com.example.signbee.exception.SignbeeApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Objects;
@Service
public class SignbeeService {
private static final Logger log = LoggerFactory.getLogger(SignbeeService.class);
private final RestClient restClient;
private final SignbeeProperties properties;
public SignbeeService(RestClient signbeeRestClient, SignbeeProperties properties) {
this.restClient = signbeeRestClient;
this.properties = properties;
}
/**
* Dispatches a document for signature via the Signbee REST API.
*/
public SendDocumentResponse sendDocument(SendDocumentRequest request) {
log.info("Dispatching document to recipient: {}", request.recipientEmail());
// Attach default webhook URL if not explicitly provided
SendDocumentRequest payload = request.webhookUrl() == null || request.webhookUrl().isBlank()
? new SendDocumentRequest(
request.markdown(),
request.recipientName(),
request.recipientEmail(),
properties.webhook().callbackUrl(),
request.title(),
request.metadata()
)
: request;
return restClient.post()
.uri("/send")
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, response) -> {
String errorBody = new String(response.getBody().readAllBytes());
log.error("Signbee 4xx Client Error [{}]: {}", response.getStatusCode(), errorBody);
throw new SignbeeApiException("Signbee client error: " + errorBody, response.getStatusCode());
})
.onStatus(HttpStatusCode::is5xxServerError, (req, response) -> {
String errorBody = new String(response.getBody().readAllBytes());
log.error("Signbee 5xx Server Error [{}]: {}", response.getStatusCode(), errorBody);
throw new SignbeeApiException("Signbee server error: " + errorBody, response.getStatusCode());
})
.body(SendDocumentResponse.class);
}
/**
* Downloads the final signed PDF bytes directly from the secure endpoint.
*/
public byte[] downloadSignedPdf(String documentId) {
log.info("Downloading finalized PDF for document ID: {}", documentId);
Objects.requireNonNull(documentId, "documentId cannot be null");
return restClient.get()
.uri("/documents/{id}/download", documentId)
.accept(MediaType.APPLICATION_PDF)
.retrieve()
.body(byte[].class);
}
}Step 4: Timing-Safe HMAC-SHA256 Webhook Verification
When an agreement is viewed, signed, or declined, Signbee sends an HTTP POST webhook to your configured callback endpoint. To ensure the request originated from Signbee and was not tampered with in transit, Signbee sends an HMAC-SHA256 signature in the X-Signbee-Signature header.
Why Constant-Time Comparison (MessageDigest.isEqual) Is Mandatory
Standard Java string comparison (str1.equals(str2)) stops evaluation at the exact character where a mismatch occurs. By measuring response latency in nanoseconds, attackers can use statistical timing analysis to forge valid signatures character-by-character. Java's MessageDigest.isEqual() evaluates all bytes in constant time regardless of where differences occur, completely neutralizing timing oracle attacks.
Here is the production-grade signature verification utility using standard javax.crypto.Mac:
package com.example.signbee.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
@Component
public class WebhookSignatureVerifier {
private static final Logger log = LoggerFactory.getLogger(WebhookSignatureVerifier.class);
private static final String HMAC_ALGORITHM = "HmacSHA256";
/**
* Verifies that the raw HTTP request body matches the X-Signbee-Signature header.
* Uses MessageDigest.isEqual() for constant-time evaluation.
*/
public boolean isValidSignature(String rawBody, String incomingSignatureHeader, String secret) {
if (rawBody == null || incomingSignatureHeader == null || secret == null) {
log.warn("Webhook signature validation rejected: missing payload, header, or secret");
return false;
}
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
SecretKeySpec secretKeySpec = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
HMAC_ALGORITHM
);
mac.init(secretKeySpec);
byte[] computedHmac = mac.doFinal(rawBody.getBytes(StandardCharsets.UTF_8));
String computedHexSignature = HexFormat.of().formatHex(computedHmac);
// Clean any potential "sha256=" prefix from header
String cleanIncomingSignature = incomingSignatureHeader.startsWith("sha256=")
? incomingSignatureHeader.substring(7)
: incomingSignatureHeader;
byte[] expectedBytes = computedHexSignature.getBytes(StandardCharsets.UTF_8);
byte[] actualBytes = cleanIncomingSignature.getBytes(StandardCharsets.UTF_8);
// CRITICAL: Constant-time comparison prevents timing attacks
boolean isValid = MessageDigest.isEqual(expectedBytes, actualBytes);
if (!isValid) {
log.warn("Invalid webhook signature received from remote sender");
}
return isValid;
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("Cryptographic error computing webhook HMAC: {}", e.getMessage(), e);
return false;
}
}
}Now, create the Spring MVC Webhook Controller. We inject the raw JSON payload as a String to calculate the exact hash before Jackson deserialization:
package com.example.signbee.controller;
import com.example.signbee.config.SignbeeProperties;
import com.example.signbee.dto.WebhookEvent;
import com.example.signbee.security.WebhookSignatureVerifier;
import com.example.signbee.service.ContractLifecycleService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/webhooks")
public class SignbeeWebhookController {
private static final Logger log = LoggerFactory.getLogger(SignbeeWebhookController.class);
private final WebhookSignatureVerifier signatureVerifier;
private final ContractLifecycleService lifecycleService;
private final SignbeeProperties properties;
private final ObjectMapper objectMapper;
public SignbeeWebhookController(
WebhookSignatureVerifier signatureVerifier,
ContractLifecycleService lifecycleService,
SignbeeProperties properties,
ObjectMapper objectMapper
) {
this.signatureVerifier = signatureVerifier;
this.lifecycleService = lifecycleService;
this.properties = properties;
this.objectMapper = objectMapper;
}
@PostMapping("/signbee")
public ResponseEntity<String> handleSignbeeWebhook(
@RequestBody String rawPayload,
@RequestHeader(value = "X-Signbee-Signature", required = false) String signatureHeader
) {
// 1. Verify Cryptographic Authenticity
boolean verified = signatureVerifier.isValidSignature(
rawPayload,
signatureHeader,
properties.webhook().secret()
);
if (!verified) {
log.warn("Rejecting unverified webhook callback with status 401");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
}
try {
// 2. Deserialize verified payload
WebhookEvent event = objectMapper.readValue(rawPayload, WebhookEvent.class);
log.info("Processing verified Signbee webhook: event={}, documentId={}",
event.event(), event.data().documentId());
// 3. Delegate to transactional lifecycle service
lifecycleService.processWebhookEvent(event);
// 4. Return immediate 200 OK
return ResponseEntity.ok("Webhook processed successfully");
} catch (Exception e) {
log.error("Error processing verified webhook payload: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Internal processing error");
}
}
}For a deep dive into webhook retry mechanics and state machines, check out our guide on E-Signature API Webhook Events & Lifecycle Tracking.
Step 5: Persist Contract Lifecycle & Audit Trails with JPA
Legal compliance frameworks (ESIGN, eIDAS, and HIPAA) mandate a tamper-evident audit record detailing when the agreement was generated, viewed, signed, and the digital certificate hash attached to the artifact.
Define a JPA entity ContractAuditRecord.java and its status enumeration:
package com.example.signbee.entity;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "contract_audit_records", indexes = {
@Index(name = "idx_contract_doc_id", columnList = "document_id", unique = true),
@Index(name = "idx_contract_status", columnList = "status"),
@Index(name = "idx_contract_recipient", columnList = "recipient_email")
})
public class ContractAuditRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "document_id", nullable = false, length = 64, unique = true)
private String documentId;
@Column(name = "recipient_name", nullable = false)
private String recipientName;
@Column(name = "recipient_email", nullable = false)
private String recipientEmail;
@Column(name = "title", length = 255)
private String title;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
private ContractStatus status;
@Column(name = "signing_url", length = 512)
private String signingUrl;
@Column(name = "signed_pdf_url", length = 512)
private String signedPdfUrl;
@Column(name = "certificate_hash", length = 128)
private String certificateHash;
@Column(name = "signer_ip", length = 64)
private String signerIp;
@Column(name = "signer_user_agent", length = 255)
private String signerUserAgent;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "signed_at")
private Instant signedAt;
@Column(name = "updated_at")
private Instant updatedAt;
public enum ContractStatus {
PENDING_DISPATCH,
SENT,
VIEWED,
SIGNED,
DECLINED,
EXPIRED
}
@PrePersist
protected void onCreate() {
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = Instant.now();
}
// Default Constructor for JPA
public ContractAuditRecord() {}
public ContractAuditRecord(String documentId, String recipientName, String recipientEmail,
String title, ContractStatus status, String signingUrl) {
this.documentId = documentId;
this.recipientName = recipientName;
this.recipientEmail = recipientEmail;
this.title = title;
this.status = status;
this.signingUrl = signingUrl;
}
// Getters and Business Mutation Methods
public Long getId() { return id; }
public String getDocumentId() { return documentId; }
public ContractStatus getStatus() { return status; }
public String getSignedPdfUrl() { return signedPdfUrl; }
public String getCertificateHash() { return certificateHash; }
public void markViewed(Instant timestamp, String ip, String userAgent) {
if (this.status == ContractStatus.SENT) {
this.status = ContractStatus.VIEWED;
this.signerIp = ip;
this.signerUserAgent = userAgent;
}
}
public void markSigned(Instant signedAt, String signedPdfUrl, String certificateHash, String ip, String userAgent) {
this.status = ContractStatus.SIGNED;
this.signedAt = signedAt;
this.signedPdfUrl = signedPdfUrl;
this.certificateHash = certificateHash;
this.signerIp = ip;
this.signerUserAgent = userAgent;
}
public void markDeclined(Instant declinedAt, String ip, String userAgent) {
this.status = ContractStatus.DECLINED;
this.signerIp = ip;
this.signerUserAgent = userAgent;
}
}Create the Spring Data JPA Repository interface:
package com.example.signbee.repository;
import com.example.signbee.entity.ContractAuditRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface ContractAuditRepository extends JpaRepository<ContractAuditRecord, Long> {
Optional<ContractAuditRecord> findByDocumentId(String documentId);
boolean existsByDocumentId(String documentId);
}Now wire the webhook event processing in a transactional service class ContractLifecycleService.java:
package com.example.signbee.service;
import com.example.signbee.dto.WebhookData;
import com.example.signbee.dto.WebhookEvent;
import com.example.signbee.entity.ContractAuditRecord;
import com.example.signbee.repository.ContractAuditRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ContractLifecycleService {
private static final Logger log = LoggerFactory.getLogger(ContractLifecycleService.class);
private final ContractAuditRepository repository;
public ContractLifecycleService(ContractAuditRepository repository) {
this.repository = repository;
}
@Transactional
public void processWebhookEvent(WebhookEvent event) {
WebhookData data = event.data();
String documentId = data.documentId();
ContractAuditRecord record = repository.findByDocumentId(documentId)
.orElseGet(() -> {
log.warn("Received webhook for untracked document: {}. Creating fallback record.", documentId);
return new ContractAuditRecord(
documentId,
data.signerName(),
data.signerEmail(),
"External Agreement",
ContractAuditRecord.ContractStatus.SENT,
null
);
});
switch (event.event()) {
case "document.viewed" -> {
log.info("Document {} viewed by {}", documentId, data.signerEmail());
record.markViewed(event.timestamp(), data.ipAddress(), data.userAgent());
}
case "document.signed" -> {
log.info("Document {} successfully SIGNED! Hash: {}", documentId, data.certificateHash());
record.markSigned(
event.timestamp(),
data.signedPdfUrl(),
data.certificateHash(),
data.ipAddress(),
data.userAgent()
);
// Trigger downstream business automation (e.g., provisioning account, billing)
}
case "document.declined" -> {
log.warn("Document {} DECLINED by {}", documentId, data.signerEmail());
record.markDeclined(event.timestamp(), data.ipAddress(), data.userAgent());
}
default -> log.info("Unhandled webhook event type: {}", event.event());
}
repository.save(record);
}
}Step 6: Complete End-to-End Business Flow & Mock Testing
Here is how a business controller generates dynamic Markdown contracts (such as an Employee IP Assignment or B2B SaaS Order Form), dispatches them through SignbeeService, and immediately persists the initial audit record:
package com.example.signbee.controller;
import com.example.signbee.dto.SendDocumentRequest;
import com.example.signbee.dto.SendDocumentResponse;
import com.example.signbee.entity.ContractAuditRecord;
import com.example.signbee.repository.ContractAuditRepository;
import com.example.signbee.service.SignbeeService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/contracts")
public class ContractDispatchController {
private final SignbeeService signbeeService;
private final ContractAuditRepository auditRepository;
public ContractDispatchController(SignbeeService signbeeService, ContractAuditRepository auditRepository) {
this.signbeeService = signbeeService;
this.auditRepository = auditRepository;
}
public record DispatchDto(String employeeName, String employeeEmail, String role, String salary) {}
@PostMapping("/send-offer-letter")
public ResponseEntity<Map<String, String>> sendOfferLetter(@RequestBody DispatchDto dto) {
// Construct dynamic Markdown contract
String markdown = """
# Employment Offer & Intellectual Property Agreement
**Employer:** Enterprise Cloud Systems Inc.
**Candidate:** %s
**Email:** %s
**Position:** %s
**Annual Base Salary:** %s
---
### 1. Proprietary Information and Inventions
The employee agrees that all inventions, software modifications, designs, and intellectual
property authored during employment belong exclusively to Enterprise Cloud Systems Inc.
### 2. At-Will Employment
This offer represents an at-will employment relationship subject to standard background checks.
""".formatted(dto.employeeName(), dto.employeeEmail(), dto.role(), dto.salary());
SendDocumentRequest request = new SendDocumentRequest(
markdown,
dto.employeeName(),
dto.employeeEmail(),
null, // Uses default application.yml webhook
"Offer Letter - " + dto.employeeName(),
Map.of("role", dto.role())
);
// Dispatch via Signbee API
SendDocumentResponse response = signbeeService.sendDocument(request);
// Record Initial State in PostgreSQL
ContractAuditRecord auditRecord = new ContractAuditRecord(
response.documentId(),
dto.employeeName(),
dto.employeeEmail(),
"Offer Letter - " + dto.employeeName(),
ContractAuditRecord.ContractStatus.SENT,
response.signingUrl()
);
auditRepository.save(auditRecord);
return ResponseEntity.ok(Map.of(
"documentId", response.documentId(),
"signingUrl", response.signingUrl(),
"status", response.status()
));
}
}To verify this workflow in your CI/CD pipeline, write a Spring Boot Slice Test using MockRestServiceServer to simulate the remote Signbee API:
package com.example.signbee.service;
import com.example.signbee.config.SignbeeProperties;
import com.example.signbee.dto.SendDocumentRequest;
import com.example.signbee.dto.SendDocumentResponse;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@RestClientTest({SignbeeService.class, SignbeeProperties.class})
class SignbeeServiceTest {
@Autowired
private SignbeeService signbeeService;
@Autowired
private MockRestServiceServer mockServer;
@Test
void testSendDocument_Success() {
String mockResponseJson = """
{
"document_id": "doc_spring_live_98765",
"signing_url": "https://signb.ee/sign/doc_spring_live_98765",
"status": "sent",
"created_at": "2026-08-12T12:00:00Z"
}
""";
mockServer.expect(requestTo("https://signb.ee/api/v1/send"))
.andExpect(method(HttpMethod.POST))
.andExpect(header("Authorization", "Bearer sb_live_test_key"))
.andRespond(withSuccess(mockResponseJson, MediaType.APPLICATION_JSON));
SendDocumentRequest request = SendDocumentRequest.of(
"# Test Agreement", "Alice Developer", "alice@enterprise.com", "https://app.com/webhook"
);
SendDocumentResponse response = signbeeService.sendDocument(request);
assertThat(response).isNotNull();
assertThat(response.documentId()).isEqualTo("doc_spring_live_98765");
assertThat(response.signingUrl()).contains("signb.ee/sign");
mockServer.verify();
}
}Comparing Architectures: Spring Boot + Signbee vs. Legacy DocuSign SDK
Here is a side-by-side comparison of integrating e-signatures using modern Spring Boot 3 & Signbee versus the legacy DocuSign Java SDK:
| Architectural Attribute | DocuSign Java SDK | Signbee + Spring RestClient |
|---|---|---|
| Jar Dependencies | ~45MB (CXF, BouncyCastle, JAX-RS, Apache HTTP) | 0 MB (Uses Spring Starter Web & JDK) |
| Authentication Model | RSA PKCS#8 JWT Grant + Token Cache + OAuth 2.0 | Standard Bearer Token (Environment Variable) |
| Document Source | Complex Envelopes, Tabs, Coordinates, or UI Templates | Dynamic Markdown with instant PDF compilation |
| Java 21 Compatibility | Mutable JavaBeans with verbose setters | Immutable Java 21 Records + Virtual Threads |
| Webhook Security | DocuSign Connect HMAC validation | Timing-safe HMAC with MessageDigest.isEqual() |
| Code to Send Contract | ~90 lines of boilerplate setup | 12 lines of functional RestClient code |
Cross-Language & Multi-Framework Tutorials
Building a polyglot microservice ecosystem? Explore our complete step-by-step guides for other enterprise languages and stacks:
HttpClient, record types, and CryptographicOperations.FixedTimeEquals.
net/http, Gin webhook handlers, and subtle.ConstantTimeCompare.
Detailed event payloads, exponential retries, and deduplication.
Frequently Asked Questions
How does modern Spring Boot 3 RestClient compare to WebClient and legacy RestTemplate for e-signature API integration?
In Spring Framework 6.1 and Spring Boot 3.2+, RestClient was introduced as the modern, synchronous HTTP client that provides a fluent, functional API similar to WebClient but without requiring the reactive project-reactor (reactive-streams) dependency stack. For standard synchronous enterprise services running on Java 21 with virtual threads (Project Loom enabled via spring.threads.virtual.enabled=true), RestClient delivers superior developer ergonomics, clean declarative error handling via .onStatus(), straightforward Jackson record serialization, and thread-per-request blocking without reactive overhead. In contrast, legacy RestTemplate is in maintenance mode with clunky parameter binding, while WebClient remains best suited for full reactive WebFlux architectures. Using RestClient allows you to interact with the Signbee REST API with minimal boilerplate, robust connection pooling, and seamless virtual thread scalability.
Why is MessageDigest.isEqual() strictly required over String.equals() when validating e-signature webhook HMAC signatures in Java?
When receiving incoming HTTP webhook callbacks from an e-signature service, validating the HMAC-SHA256 signature is critical to prevent spoofed payloads and unauthorized state manipulation. However, standard Java String.equals() or byte array iterative comparisons short-circuit and terminate immediately upon encountering the first non-matching byte or character. This microsecond discrepancy allows sophisticated attackers executing side-channel timing attacks to measure response latency variations and sequentially deduce the expected signature byte by byte. In contrast, java.security.MessageDigest.isEqual() performs a constant-time bitwise comparison over the entire byte array regardless of where discrepancies occur. Using MessageDigest.isEqual() with javax.crypto.Mac guarantees that signature verification time remains invariant, completely eliminating timing oracle vulnerabilities in enterprise Spring Boot webhook endpoints.
How should enterprise Java applications persist immutable audit trails and comply with legal e-signature regulations (ESIGN / eIDAS)?
To ensure full legal compliance under the US ESIGN Act, UETA, and EU eIDAS regulations, enterprise applications must maintain an immutable, tamper-evident audit log for every executed agreement. In Spring Boot, this is achieved by defining a dedicated JPA Entity (such as ContractAuditRecord) mapped with Spring Data JPA. The entity records critical forensic metadata including the unique Signbee document ID, cryptographic SHA-256 certificate checksums, signer email addresses, signer IP addresses, user agents, precise UTC timestamp transitions (SENT, VIEWED, SIGNED, DECLINED), and permanent object storage URLs for the finalized signed PDF. By decoupling webhook reception from audit persistence within transactional boundaries (@Transactional), enterprise systems guarantee verifiable non-repudiation, tamper-evident integrity, and long-term regulatory audit readiness.
Start Embedding E-Signatures in Spring Boot Today
Skip the multi-megabyte SDKs and complex OAuth keys. Generate documents with pure Markdown, dispatch via RestClient, and verify signatures with zero dependencies.
Last updated: August 12, 2026 · Authored by Michael Beckett, founder of Signbee and B2bee Ltd.