May 26, 2026
26 min read

Building a customer onboarding system for PayPal Balance accounts sounds straightforward until you realize how many stakeholders need to touch the workflow:
In a traditional approach, each workflow change means code changes. Each code change means a PR, code review, testing, and a deployment cycle. In a regulated financial environment, this becomes a bottleneck. You end up with engineers becoming gatekeepers for business decisions instead of enablers.
We needed a system where workflow definitions were configuration, not code.
Before diving into implementation, let's be clear about the scope. Our onboarding workflow has roughly 11-12 checkpoints that can be arranged in different sequences:
The individual checkpoint logic is stable, we call specific downstream services for each one. But the sequence and combinations change frequently based on business requirements.
This is the key insight: if the building blocks are stable but the arrangements are variable, use configuration.
We designed our workflow definitions as JSON because:
Here's what an actual workflow definition looks like:
{
"onboarding_type": "ACCOUNT_SETUP",
"onboarding_category": "PERSONAL_ACCOUNT",
"checkpoints": [
{
"name": "CV_SEND_EMAIL_OTP",
"description": "send email OTP",
"type": "USER",
"data": {
"email_id": { "data_type": "string", "is_required": true }
}
},
{
"name": "CV_VALIDATE_EMAIL",
"description": "confirm email OTP",
"type": "USER",
"data": {
"email_id": { "data_type": "string", "is_required": true },
"code": { "data_type": "string", "is_required": true },
"timestamp": { "data_type": "string", "is_required": true },
"ip_address": { "data_type": "string", "is_required": false },
"skip_notification": { "data_type": "boolean", "is_required": false }
}
},
{
"name": "KYC_LIVE_DOC_SCAN",
"description": "live document scan",
"type": "USER",
"data": {
"doc_ref_id": { "data_type": "string", "is_required": true },
"doc_upload_ts": { "data_type": "string", "is_required": true },
"scan_status": {
"data_type": "string",
"is_required": true,
"allowed_values": ["SUCCESS", "FAILED", "PENDING"]
},
"government_id": {
"type": { "data_type": "string", "is_required": true, "allowed_values": ["SSN", "PASSPORT"] },
"value": { "data_type": "string", "is_required": true }
}
}
},
{
"name": "KYC_INFO",
"description": "Personal Info",
"type": "USER",
"data": {
"name": { "data_type": "string", "is_required": true },
"paternal_last_name": { "data_type": "string", "is_required": true },
"maternal_last_name": { "data_type": "string", "is_required": false },
"date_of_birth": { "data_type": "string", "is_required": true },
"gender": {
"data_type": "string",
"is_required": true,
"allowed_values": ["MALE", "FEMALE", "NOT_APPLICABLE", "NOT_KNOWN"]
},
"nationality": { "data_type": "string", "is_required": true },
"state_of_birth": { "data_type": "string", "is_required": true }
}
},
{
"name": "KYC_ADDRESS",
"description": "Work Address",
"type": "USER",
"data": {
"work_address": {
"line1": { "data_type": "string", "is_required": true },
"line2": { "data_type": "string", "is_required": false },
"city": { "data_type": "string", "is_required": true },
"state": { "data_type": "string", "is_required": true },
"zip": { "data_type": "string", "is_required": true },
"country": { "data_type": "string", "is_required": true }
}
}
}
]
}Each checkpoint is self-contained: it knows what data it requires, what the data types are, what fields are optional, and what constraints apply.
During the design phase, we mapped out the actual scenarios we'd encounter:
A requirement comes to add maternal_last_name to KYC_INFO:
KYC_INFO checkpoint schema in the JSONrequired: false and wait for upstream to integrate so nothing breaks even if we push the code and later once the system is stable change it to required: trueNo code changes. No deployment. Just a configuration update.
Sometimes a field should only be required if another field has a certain value. Instead of hardcoding this in Java, we express it in the schema:
"upper_bound": { "data_type": "integer" }This field is optional. The checkpoint handler can decide whether to require it based on the actual data received. The schema defines possibilities; the handler defines logic.
Legal needs to update the agreement template. They can't just overwrite it—regulatory audits require tracking which version each customer accepted. We handle this with version fields:
"major_version": { "data_type": "integer", "is_required": false },
"minor_version": { "data_type": "integer", "is_required": false }When a customer reaches the legal agreement checkpoint, they see the current version. We store which version they accepted.
The system has three main layers:
These are the source of truth. They define checkpoints, sequences, and data schemas. They're versioned in Git like any configuration.
When a request comes in for a checkpoint, the validator:
This happens synchronously for every request.
Once validated, the request goes to a handler. Each checkpoint type has a handler that knows how to call downstream services:
CV_SEND_EMAIL_OTP → ContactVerificationHandler → contactVerificationClient.sendOTP()
KYC_LIVE_DOC_SCAN → DocumentScanHandler → documentLifeCycleClient.scan()
LEGAL_AGREEMENT → AgreementHandler → generateAgreement() + SignatureUtil.sign()The handlers are stable. We add new handlers rarely. Most work is configuration changes.
Here's how the validation works in practice:
@Component
public class CheckpointRequestValidator implements RequestValidator<CheckpointRequest, Void> {
private final ValidationConfig validationConfig;
private final ValidationUtil validationUtil;
@Override
public Void validate(CheckpointRequest request, Map<String, String> headers)
throws ValidationException {
List<ErrorDetail> errors = new ArrayList<>();
String entity = headers.get(ENTITY);
String userId = headers.get(USER_ID);
String transactionId = headers.get(TRANSACTION_ID);
String checkpointId = request.getCheckpointId();
validateEntity(entity, errors);
validateUserId(userId, errors);
validateTransactionId(transactionId, errors);
validateCheckpointType(checkpointId, errors);
if (!errors.isEmpty()) {
throw new ValidationException(INVALID_REQUEST.getCode(),
INVALID_REQUEST.getMessage(), null, errors);
}
return null;
}
}And the flow schema validation ensures the workflow itself is valid:
@Component
public class OnboardingFlowSchemaValidator implements RequestValidator<Void, Void> {
private final ValidationConfig validationConfig;
@Override
public Void validate(Void request, Map<String, String> headers)
throws ValidationException {
String entity = headers.get(ENTITY);
String type = headers.get(TYPE);
String category = headers.get(CATEGORY);
List<ErrorDetail> errors = new ArrayList<>();
// Validate that this combination of entity/type/category is supported
// Validate that the JSON workflow file exists and is valid
// Validate that all required fields are present in the schema
if (!errors.isEmpty()) {
throw new ValidationException(INVALID_REQUEST.getCode(),
INVALID_REQUEST.getMessage(), null, errors);
}
return null;
}
}We validate at two points:
This catches configuration errors early and prevents bad data from reaching handlers.
Checkpoints are discovered by name, not registration:
Checkpoint name: "CV_VALIDATE_EMAIL"
Handler class: "ContactVerificationHandler" (derived from checkpoint prefix)This reduces boilerplate. Add a checkpoint to the JSON, and if a matching handler exists, it works. If not, we fail with a clear error.
When we start an onboarding transaction, we load and store the workflow version it uses. If we update the JSON later, existing transactions continue with their original workflow.
This prevents a customer halfway through the process from suddenly hitting new fields or reordered checkpoints.
In distributed systems, requests get retried. If a checkpoint execution succeeds but the response is lost, the customer might resubmit. We handle this with by pulling the data from onboarding data.
This prevents duplicate processing. If a customer verifies their email twice, the second request returns the same response as the first without calling the verification service again.
We use a factory to instantiate the correct handler:
DocumentScanHandlerFactory → DocumentScanHandler
LegalAgreementHandlerFactory → AgreementHandlerThis allows:
By expressing constraints in JSON instead of code, we catch configuration errors immediately. If someone updates the schema to require a new field, the next request that hits that checkpoint fails validation with a clear error message, long before the handler tries to process it.
We use Maven profiles to support AWS (DynamoDB), GCP (Spanner), and local deployments:
mvn clean install -Paws # AWS DynamoDB
mvn clean install -Pgcp # GCP Spanner
mvn clean install -Plocal # Local testingThe workflow engine is cloud-agnostic. Different backends, same logic.
Every checkpoint execution is logged:
This audit trail is essential. When something goes wrong, you need to know exactly what was received and what was returned.
We track major/minor versions for legal agreements. If the legal team updates an agreement template, new customers get the new version. Old customers continue with the version they accepted.
Best fit when:
When workflow logic lives in JSON, your documentation is your source of truth. We maintain:
Because you can't rely on compile-time checking, runtime validation has to be solid. We validate:
Workflows change. Schemas evolve. Checkpoint handlers get updated. Without versioning:
We version workflows with the file name and schemas within the JSON.
This architecture is built on a simple idea: if it changes frequently and is decided by non-engineers, it should be configuration, not code.
Workflows fit that description perfectly. KYC requirements, legal agreements, compliance checks—these are business decisions. They shouldn't live in Java code.
By extracting workflows into JSON:
The initial investment was real, we had to build validation, handler factories, versioning, and comprehensive logging. But the payoff is a system that moves at the speed of business, not the speed of engineering sprints.

5 min read

5 min read

5 min read