Building a Production-Grade Custom Signup Workflow for WSO2 API Manager: A Complete Tutorial
How to replace WSO2 APIM’s silent developer-signup workflow with branded HTML emails, a hermetic test suite, and a real production bug fix…
Building a Production-Grade Custom Signup Workflow for WSO2 API Manager: A Complete Tutorial
How to replace WSO2 APIM’s silent developer-signup workflow with branded HTML emails, a hermetic test suite, and a real production bug fix step by step, from an empty Maven project to a deployed OSGi bundle
If you’ve ever run WSO2 API Manager in a real developer-onboarding pipeline, you’ve probably hit this wall: a developer signs up through the Developer Portal, and then… nothing. No confirmation email. No alert to your admin team. The request just sits in the Admin Console’s pending-tasks list, invisible until someone happens to go looking for it.
WSO2 gives you the mechanism to fix this a workflow extension system that lets you hook custom Java code into the signup lifecycle but it doesn’t give you the implementation. You have to build it yourself, inside an OSGi container, against WSO2’s internal APIs, with very little room for error.
This article walks through building that implementation completely from scratch: every requirement, every technology decision, every problem encountered (including a real silent-failure bug found through production logs, not a test case), and the actual fix. By the end you’ll have a working, tested, deployable custom workflow executor and a much better understanding of how WSO2’s OSGi extension model actually works under the hood.
This is long. It’s meant to be. Grab a coffee.
Photo by Nathan Dumlao on Unsplash
Table of Contents
- The problem we’re solving
- Understanding WSO2’s workflow extension model
- Why OSGi changes everything about how you build this
- Setting up the Maven project
- Building the executor step by step
- Designing the email templates
- XSS: the escaping gap nobody thinks about
- Deploying into a real WSO2 instance
- The deployment problems you will hit (and how to fix them)
- Building a real test suite not just mocks
- The race condition hiding in your test suite
- The production bug: silent rejection emails
- Final project structure and what you’ve built
- Lessons that generalize beyond this project
1. The problem we’re solving
WSO2 API Manager’s default developer-signup workflow (UserSignUpApprovalWorkflowExecutor) is functionally complete but operationally silent. Walk through what actually happens with zero customization:
- A developer fills out the signup form on the Developer Portal and submits.
- WSO2 creates a pending workflow record in its internal database.
- Nothing notifies anyone. The admin has to manually check the Admin Console’s pending-tasks list.
- Eventually, an admin approves or rejects the request.
- Nothing notifies the applicant either. They find out by trying to log in.
For a small internal tool, this might be tolerable. For any real onboarding pipeline partner API programs, B2B integrations, internal developer platforms with actual SLAs this silence is a genuine operational gap, and it’s exactly the kind of thing that erodes trust in a platform quietly, one frustrated developer at a time.
What we’re going to build closes this gap completely:
- Instant admin alert the moment someone submits a signup, with a direct link into the review queue.
- Instant applicant acknowledgment “we got your request” so nobody is left wondering if the form actually submitted.
- Outcome notification when the admin decides a warm welcome on approval, a soft and supportive message on rejection.
- A resilience fix for a genuine bug we’ll hit partway through: rejected applicants silently not getting notified because their account record gets removed before the notification logic runs.
All four emails will share one visual design system, render with zero external templating dependencies, and be hardened against HTML injection from malicious usernames.
2. Understanding WSO2’s workflow extension model
Before writing a single line of code, you need to understand the shape of the API you’re extending, because it has a consequence that shapes the entire architecture.
WSO2 exposes workflow extension points by letting you subclass specific executor base classes and register your subclass in server configuration. For developer signup specifically, the relevant base class is:
org.wso2.carbon.apimgt.impl.workflow.UserSignUpApprovalWorkflowExecutor
This class exposes exactly two lifecycle methods you can override:
public WorkflowResponse execute(WorkflowDTO workflowDTO) throws WorkflowException
public WorkflowResponse complete(WorkflowDTO workflowDTO) throws WorkflowException
Here’s the part that matters architecturally: these are not two steps of one continuous method call. execute() runs once, synchronously, when the applicant submits the form. complete() runs separately triggered by an entirely different HTTP request whenever the admin clicks Approve or Reject in the Admin Console, which could be seconds or days later.
This means any state you want to share between Stage 1 and Stage 2 like “what was this applicant’s email address?” cannot just be a local variable. It has to be persisted somewhere external to the call stack: a database, a cache, or some other mechanism that survives between two genuinely separate method invocations, possibly handled by different threads, possibly after a server restart.
Keep this in mind. It’s going to come back and bite us in Section 12, in a very real way.
Photo by Brett Jordan on Unsplash
3. Why OSGi changes everything about how you build this
WSO2 API Manager runs inside Eclipse Equinox, an OSGi (Open Services Gateway initiative) container. If you’ve only ever built standard Spring Boot or plain Java applications, OSGi’s classloading model is going to surprise you.
In a normal JVM application, every JAR on the classpath shares one global classloader any class can see any other class. OSGi deliberately breaks this. Each “bundle” (an OSGi-flavored JAR) gets its own isolated classloader, and bundles can only see classes from other bundles if there’s an explicit Import-Package/Export-Package agreement between them, declared in the bundle's manifest.
Why this matters for us: if you reach for a templating library like Thymeleaf or FreeMarker to render your HTML emails which is the natural instinct and just add it as a normal Maven dependency, your bundle will fail at runtime with NoClassDefFoundError, because Equinox has no idea that templating library's classes are supposed to be visible to your bundle. Fixing this properly requires either bundling the dependency inside your JAR with careful Import-Package/Export-Package wiring (fragile, easy to get subtly wrong) or convincing WSO2 to expose that library as its own OSGi bundle (often not feasible).
The decision this project makes: zero external runtime dependencies beyond what Carbon already exports. HTML is built with plain Java string concatenation. SMTP uses javax.mail, which WSO2 already provides. This trades a bit of templating ergonomics for dramatically higher deployment reliability for four templates concatenated in one file, that's a completely reasonable trade.
4. Setting up the Maven project
Let’s start building. Create the project skeleton:
wso2-usersignup-workflow/
├── pom.xml
├── deploy.ps1
└── src/
├── main/java/com/mycompany/custom/usersignup/
└── test/java/com/mycompany/custom/usersignup/
The pom.xml needs to do three distinct jobs: declare WSO2's APIs as provided dependencies (since the runtime already supplies them bundling them would be both wasteful and a classloader conflict risk), configure the OSGi bundle plugin to generate a correct manifest, and set up the test infrastructure.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.mycompany.custom.usersignup</groupId>
<artifactId>com.mycompany.custom.usersignup.extension</artifactId>
<version>1.0.0</version>
<!--
This single line is the most consequential decision in the whole
pom.xml. <packaging>jar</packaging> would compile fine and produce
a working JAR - but it would NOT generate the OSGi manifest
entries Equinox needs to load the bundle. This mistake is
completely silent until you try to deploy.
-->
<packaging>bundle</packaging>
<name>WSO2 APIM - Custom User Signup Workflow Extension</name>
<properties>
<carbon.apimgt.version>9.28.1</carbon.apimgt.version>
<carbon.kernel.version>4.7.0</carbon.kernel.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- These are ALL scope=provided. WSO2's runtime already
exports these packages. Declaring them as compile-scope
would bundle duplicate copies into your JAR -- a classic
source of OSGi "duplicate bundle" conflicts at deploy time. -->
<dependency>
<groupId>org.wso2.carbon.apimgt</groupId>
<artifactId>org.wso2.carbon.apimgt.impl</artifactId>
<version>${carbon.apimgt.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.wso2.carbon</groupId>
<artifactId>org.wso2.carbon.user.core</artifactId>
<version>${carbon.kernel.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>javax.mail</groupId>
<artifactId>mail</artifactId>
<version>1.4.7</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<version>5.1.8</version>
<extensions>true</extensions>
<configuration>
<instructions>
<Bundle-SymbolicName>${project.artifactId}</Bundle-SymbolicName>
<Bundle-Name>${project.name}</Bundle-Name>
<Export-Package>
com.mycompany.custom.usersignup.*;version="${project.version}"
</Export-Package>
<!--
Version ranges here are deliberate. They say
"I need something compatible with 9.x, but
never load me against a breaking major-version
change" -- a defensive habit worth keeping for
any OSGi bundle that depends on a host
platform's internal APIs.
-->
<Import-Package>
org.wso2.carbon.apimgt.impl.workflow.*;version="[9.0.0, 10.0.0)",
org.wso2.carbon.context.*;version="[4.0.0, 5.0.0)",
org.wso2.carbon.user.api.*;version="[1.0.1, 2.0.0)",
org.apache.commons.logging.*;version="[1.2, 2.0)",
javax.mail.*;version="[1.4, 2.0)",
*;resolution:=optional
</Import-Package>
</instructions>
</configuration>
</plugin>
</plugins>
</build>
</project>
*;resolution:=optional at the bottom of Import-Package is a pragmatic catch-all: it tells Equinox "import anything else this code touches, but don't fail to load the bundle if some of those optional imports can't be resolved." Without it, a single transitively-referenced class your code never actually executes at runtime could block the entire bundle from activating.
5. Building the executor — step by step
Now the actual implementation. Let’s build CustomUserSignUpWorkflowExecutor incrementally, starting with the skeleton.
Step 5.1 — The class shell and configuration properties
public class CustomUserSignUpWorkflowExecutor extends UserSignUpApprovalWorkflowExecutor {
private static final Log log = LogFactory.getLog(CustomUserSignUpWorkflowExecutor.class);
private static final String EMAIL_CLAIM_URI = "http://wso2.org/claims/emailaddress";
private String mailSmtpHost = "localhost";
private String mailSmtpPort = "1025";
private String mailFromAddress = "apim-noreply@example.com";
private String mailFromName = "WSO2 API Manager";
private String adminUsername = "admin";
private String portalUrl = "https://localhost:9443/devportal";
// Standard JavaBean getter/setter pairs for every field above.
public void setMailSmtpHost(String v) { this.mailSmtpHost = v; }
public String getMailSmtpHost() { return mailSmtpHost; }
// ... (repeat for every property)
}
Why getters and setters for fields nothing else in the code calls directly? Because WSO2’s configuration loader populates these fields via reflection, reading <Property> entries from workflow-extensions.xml and calling the matching setter by name. This getter/setter pair isn't boilerplate you can clean up later it's the actual integration contract with WSO2's configuration system.
Step 5.2 — The two lifecycle hooks
@Override
public String getWorkflowType() {
return "AM_USER_SIGNUP";
}
@Override
public WorkflowResponse execute(WorkflowDTO workflowDTO) throws WorkflowException {
WorkflowResponse response = super.execute(workflowDTO);
String username = workflowDTO.getWorkflowReference();
log.info("Signup submitted by: " + username + " - notifying admin and user.");
sendAdminAlert(username);
sendUserPendingAlert(username);
return response;
}
@Override
public WorkflowResponse complete(WorkflowDTO workflowDTO) throws WorkflowException {
WorkflowResponse response = super.complete(workflowDTO);
String username = workflowDTO.getWorkflowReference();
WorkflowStatus status = workflowDTO.getStatus();
log.info("Admin decision for user: " + username + " - status: " + status);
sendUserNotification(username, status);
return response;
}
Notice that super.execute() / super.complete() is called first, before any notification logic. This ordering is deliberate, not incidental: WSO2's own state-machine persistence has to complete successfully before we attempt any side effects. If email dispatch ran first and the actual database commit then failed, an applicant could receive an "approved!" email for a request that was never actually persisted as approved a far worse failure mode than a missing email.
Photo by Jake Hills on Unsplash
Step 5.3 — Resolving user email claims
WSO2 doesn’t give you a direct “get this user’s email” method. Instead, you go through the Carbon UserStoreManager, asking for a specific claim URI WSO2's generalized attribute system, where http://wso2.org/claims/emailaddress is the well-known claim for email:
private void sendAdminAlert(String username) {
try {
PrivilegedCarbonContext ctx = PrivilegedCarbonContext.getThreadLocalCarbonContext();
UserStoreManager usm = ctx.getUserRealm().getUserStoreManager();
String resolvedAdmin = (adminUsername != null && !adminUsername.isEmpty())
? adminUsername
: ctx.getUserRealm().getRealmConfiguration().getAdminUserName();
String adminEmail = usm.getUserClaimValue(resolvedAdmin, EMAIL_CLAIM_URI, null);
String userEmail = usm.getUserClaimValue(username, EMAIL_CLAIM_URI, null);
String adminUrl = portalUrl.replace("devportal", "admin");
String submittedAt = ZonedDateTime.now(ZoneOffset.UTC)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'"));
if (adminEmail != null && !adminEmail.isEmpty()) {
sendEmail(adminEmail,
"Action required: pending developer registration",
getAdminPendingTemplate(username, userEmail, adminUrl, submittedAt));
} else {
log.warn("No email claim found for admin: " + resolvedAdmin);
}
} catch (UserStoreException e) {
log.error("Error reading claims during admin alert dispatch", e);
}
}
A few design choices worth calling out:
**PrivilegedCarbonContext.getThreadLocalCarbonContext()** is how you get at the currently-executing tenant's user realm WSO2's multi-tenancy model means there's no single global user store, it's always tied to the thread's current tenant context.- The admin username fallback if
adminUsernameisn't explicitly configured, we fall back to the realm's own configured admin user, rather than hard failing. **adminUrlis derived, not separately configured** taking the developer portal URL and string-replacingdevportal→adminavoids requiring a duplicate configuration property for something that's almost always a predictable URL transformation.
Step 5.4 — Sending the actual email
private void sendEmail(String recipient, String subject, String htmlContent) {
Properties props = System.getProperties();
props.setProperty("mail.smtp.host", mailSmtpHost);
props.setProperty("mail.smtp.port", mailSmtpPort);
Session session = Session.getInstance(props);
try {
MimeMessage msg = new MimeMessage(session);
msg.setFrom(new InternetAddress(mailFromAddress, mailFromName));
msg.addRecipient(Message.RecipientType.TO, new InternetAddress(recipient));
msg.setSubject(subject);
msg.setContent(htmlContent, "text/html; charset=utf-8");
Transport.send(msg);
log.info("Email dispatched to: " + recipient);
} catch (MessagingException | UnsupportedEncodingException e) {
log.error("Failed to send email to: " + recipient, e);
}
}
This is intentionally synchronous and intentionally fail-soft. A failed SMTP send is logged and swallowed, never propagated as an exception because a notification failure should never be allowed to break the actual signup workflow it’s attached to. Your applicant being unable to receive a confirmation email is a much smaller problem than their entire signup request silently failing because your SMTP relay had a bad five minutes.
6. Designing the email templates
With the plumbing in place, let’s build the actual HTML. Four templates, all sharing one CSS block so a single style change propagates everywhere:
private String sharedCss() {
return "<style>"
+ "body{margin:0;padding:32px 16px;background:#f1f5f9;"
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"
+ "color:#334155}"
+ ".wrap{max-width:540px;margin:0 auto;background:#fff;border-radius:12px;"
+ "border:1px solid #e2e8f0;overflow:hidden}"
+ ".hdr{background:#0f172a;padding:22px 28px;display:flex;align-items:center;gap:12px}"
// ... full design system continues
+ "</style>";
}
Design decisions worth explaining, not just showing:
- Dark header bar (
#0f172a) on every email consistent branding means an applicant scanning their inbox can instantly recognize "this is from the platform" before even reading the subject line. - Color-coded status banners, but using soft pastel backgrounds rather than harsh saturated colors:
- Amber (
#fefce8background,#a16207text) for pending/awaiting states


- Green (
#f0fdf4/#166534) for approval

- Red (
#fef2f2/#991b1b) for rejection

- The rejection email deliberately avoids harsh language. The subject line is “An update on your registration request,” not “Registration Denied.” The body offers a visible support-contact path. This isn’t just a UX nicety a harshly-worded rejection email actively damages how an applicant perceives your platform or organization, for no functional benefit.
Here’s the approval template in full, showing the pattern every other template follows:
private String getUserApprovedTemplate(String username) {
return "<!DOCTYPE html><html lang='en'><head><meta charset='utf-8'>"
+ "<meta name='viewport' content='width=device-width,initial-scale=1'>"
+ "<title>Your developer account has been approved</title>"
+ sharedCss()
+ "</head><body><div class='wrap'>"
+ "<div class='hdr'>"
+ " <div class='hdr-icon'>◆</div>"
+ " <div><p class='hdr-sub'>WSO2 API Manager</p>"
+ " <p class='hdr-main'>Developer Portal</p></div>"
+ "</div>"
+ "<div class='body'>"
+ " <div class='banner b-green'>"
+ " <span class='bi' style='color:#166534'>✔</span>"
+ " <div>"
+ " <p class='bt green-title'>Your account has been approved</p>"
+ " <p class='bb green-body'>The administrator has reviewed your "
+ "request and granted you access to the Developer Portal.</p>"
+ " </div>"
+ " </div>"
+ " <p class='gname'>Welcome, " + esc(username) + "</p>"
+ " <a href='" + portalUrl + "' class='btn-green'>Go to Developer Portal →</a>"
+ "</div>"
+ "</div></body></html>";
}
Notice esc(username) wrapping the one piece of genuinely user-controlled data going into this template. We'll come back to exactly why that function looks the way it does in Section 7 there's a real gap here that a parameterized test will eventually expose.
7. XSS: the escaping gap nobody thinks about
It’s tempting to think “I’m just escaping a username, how complicated can this be?” Here’s the first version most people write:
private String esc(String s) {
if (s == null) return "";
return s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
This covers the obvious case a username like <script>alert('xss')</script> gets neutralized, since < and > become harmless entities and the string can no longer be parsed as an actual <script> tag.
But consider this username instead: <img src=x onerror=alert(1)>
Run it through the escaper above:
INPUT: <img src=x onerror=alert(1)>
OUTPUT: <img src=x onerror=alert(1)>
The < and > are gone, so this can no longer be parsed as an actual <img> HTML element good. But look closely: the literal substring onerror=alert(1) is still sitting there, completely intact, because = was never in the escape list.
Does this matter if it can’t be parsed as a tag anymore? In the strictest sense, no most rendering contexts won’t execute it. But it’s a real gap: depending on how an email client or downstream system might parse or re-render this content (some webmail clients do partial re-sanitization passes, some log aggregators index raw email bodies and render them elsewhere), leaving a literal, well-formed-looking attribute=value pair sitting in your output is needless residual risk for one extra line of code.
The fix:
private String esc(String s) {
if (s == null) return "";
return s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
.replace("=", "=");
}
Equally important: what not to add. An earlier draft of this code also escaped backtick (``) and forward-slash (/`). Both were mistakes:
- Backtick escaping is relevant to JavaScript template-literal injection (
${...}syntax) completely irrelevant in an HTML-rendering context. Escaping it accomplishes nothing here. - Forward-slash escaping is actively harmful.
esc()gets applied to values sitting near URLs elsewhere in these templates. The moment you escape/, everyhttps://...link in any email touched by that logic gets corrupted into garbage.
This is a useful general lesson: more escaping isn’t automatically more secure. Every character you add to an escape table needs to be justified by an actual attack vector relevant to that specific rendering context otherwise you’re just adding surface area for new bugs while providing zero additional protection.
8. Deploying into a real WSO2 instance
With the executor built, let’s get it running against an actual WSO2 APIM 4.2.0 instance.
Step 8.1 — Build the bundle
mvn clean install
This produces target/com.mycompany.custom.usersignup.extension-1.0.0.jar already correctly OSGi-manifested thanks to the maven-bundle-plugin configuration from Section 4.
Step 8.2 — Drop it into the dropins folder
WSO2 Carbon watches a specific directory for OSGi bundles it should load alongside its own components:
Copy-Item "target\com.mycompany.custom.usersignup.extension-1.0.0.jar" `
"$env:APIM_HOME\repository\components\dropins\"
Step 8.3 — Register the executor in configuration
Edit <APIM_HOME>/repository/conf/workflow-extensions.xml:
<WorkFlowExtensions>
<UserSignUp executor="com.mycompany.custom.usersignup.CustomUserSignUpWorkflowExecutor">
<Property name="mailSmtpHost">localhost</Property>
<Property name="mailSmtpPort">1025</Property>
<Property name="mailFromAddress">apim-noreply@example.com</Property>
<Property name="mailFromName">WSO2 API Manager</Property>
<Property name="adminUsername">admin</Property>
<Property name="portalUrl">https://localhost:9443/devportal</Property>
</UserSignUp>
</WorkFlowExtensions>
This is exactly why every property needed a getter/setter pair back in Section 5 WSO2’s config loader reads each <Property name="..."> and calls the matching setter via reflection at startup.
Step 8.4 — Restart cleanly
cd $env:APIM_HOME\bin
.\api-manager.bat --clean
The --clean flag matters more on exactly why in the next section.
9. The deployment problems you will hit (and how to fix them)
This is the section that will save you the most debugging time. Every one of these was hit and resolved during this project’s actual development.
Problem: the --clean flag isn't optional
Equinox maintains a persistent OSGi cache in <APIM_HOME>/work/osgi/ that tracks which bundle versions are loaded. Drop a new JAR into dropins/ and restart without --clean, and you can end up with the server confidently running your old bundle version no error, no warning, just silently stale behavior that looks exactly like your fix didn't work.
Remove-Item -Recurse -Force "$env:APIM_HOME\work\osgi\*"
Remove-Item -Recurse -Force "$env:APIM_HOME\tmp\*"
This is tedious enough to get wrong manually that it’s worth automating into a deploy script (see Section 14) with a -FreshInstall flag.
Photo by Ilija Boshkov on Unsplash
Problem: <packaging>jar</packaging> vs <packaging>bundle</packaging>
Mentioned in Section 4, but worth repeating because it’s so easy to overlook: a plain jar packaging compiles and "works" in the sense that mvn package succeeds it just silently skips OSGi manifest generation. The JAR looks fine. It deploys without an error. It just never loads, because Equinox can't find the Bundle-SymbolicName it needs.
Problem: NoClassDefFoundError for anything not already in Carbon
If you add any dependency to pom.xml without scope=provided (or without a matching Import-Package entry), expect this. The remediation is always the same: either remove the dependency and find a Carbon-native equivalent, or properly wire Import-Package/Export-Package and budget real time for getting that wiring right, because OSGi version-range syntax is unforgiving of small mistakes.
Problem: CARBON is supported only between JDK 11 and JDK 17
WSO2 Carbon explicitly checks the running JDK version at startup and will refuse or warn loudly outside the 11–17 range. If JAVA_HOME happens to point at JDK 21 (a very easy mistake on a dev machine with multiple JDKs installed), you'll see this immediately. Set JAVA_HOME to something in-range before starting the server.
Problem: duplicate bundle conflicts
On Windows specifically, path-separator mismatches between how a JAR is copied into dropins/ and how Equinox indexes bundle paths can cause it to register the same bundle twice under what it perceives as two different identities. The fix is almost always the same cache-clearing step from the --clean problem above.
10. Building a real test suite not just mocks
A mocked Transport.send() only proves your code called the send method it tells you nothing about whether the resulting email is actually well-formed, correctly addressed, or contains the content you think it does. A bug where the email body silently contains the string "null" instead of an actual username would sail right through a test that only verifies verify(mockTransport).send(any()).
The fix: use a real, in-process SMTP server during tests. This project uses SubEthaSMTP’s Wiser class a minimal SMTP server you can spin up and tear down entirely inside a JUnit test class:
private static Wiser wiser;
private static int smtpPort;
@BeforeAll
static void startSmtpServer() throws Exception {
smtpPort = findFreePort();
wiser = new Wiser();
wiser.setPort(smtpPort);
wiser.start();
}
@AfterAll
static void stopSmtpServer() {
if (wiser != null) wiser.stop();
}
private static int findFreePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
Point the executor at this ephemeral port instead of a real SMTP server during setup, and your tests get to assert against actual javax.mail.internet.MimeMessage objects real subject lines, real Content-Type headers, real parsed HTML bodies:
@Test
void sendsApprovalEmail() throws Exception {
WorkflowDTO dto = buildDto(TEST_USERNAME, WorkflowStatus.APPROVED);
withCarbonContext(() -> executor.complete(dto));
assertEquals(1, receivedMessages().size());
assertEquals(TEST_USER_EMAIL, receivedMessages().get(0).getEnvelopeReceiver());
assertTrue(mimeMessageAt(0).getSubject().toLowerCase().contains("approved"));
}
Why Wiser specifically, and not the more popular GreenMail? GreenMail’s modern 2.x releases depend on jakarta.mail a different Maven coordinate and a different package namespace (jakarta.mail.*) from the javax.mail (javax.mail.*) this project and WSO2's own runtime uses throughout. Mixing the two on one classpath risks split-package conflicts and NoClassDefFoundError at test-run time. Wiser is javax.mail-native, so this conflict never arises. This is a good reminder to check transitive dependency package namespaces before adopting a test library, not just its feature set.
Mocking the WSO2 side
The other half of the test setup mocks Carbon’s user-store APIs, since we obviously don’t want tests depending on a real running WSO2 instance:
carbonContext = mock(PrivilegedCarbonContext.class);
userRealm = mock(UserRealm.class);
userStoreManager = mock(UserStoreManager.class);
when(carbonContext.getUserRealm()).thenReturn(userRealm);
when(userRealm.getUserStoreManager()).thenReturn(userStoreManager);
when(userStoreManager.getUserClaimValue(eq(TEST_USERNAME), eq(EMAIL_CLAIM_URI), isNull()))
.thenReturn(TEST_USER_EMAIL);
Since PrivilegedCarbonContext.getThreadLocalCarbonContext() is a static method, Mockito's mockStatic() is required to intercept it:
private void withCarbonContext(Runnable action) {
try (MockedStatic<PrivilegedCarbonContext> ctxStatic =
mockStatic(PrivilegedCarbonContext.class)) {
ctxStatic.when(PrivilegedCarbonContext::getThreadLocalCarbonContext)
.thenReturn(carbonContext);
action.run();
}
}
One more thing worth highlighting: testing execute() and complete() directly means also calling super.execute()/super.complete(), which would try to hit real WSO2 internals we have no interest in exercising in a unit test. The fix is a small but important seam wrap the superclass calls in package-private methods purely so a Mockito spy can intercept them:
WorkflowResponse superExecute(WorkflowDTO workflowDTO) throws WorkflowException {
return super.execute(workflowDTO);
}
WorkflowResponse superComplete(WorkflowDTO workflowDTO) throws WorkflowException {
return super.complete(workflowDTO);
}
Then in tests:
executor = spy(new CustomUserSignUpWorkflowExecutor());
doReturn(mock(WorkflowResponse.class)).when(executor).superExecute(any());
doReturn(mock(WorkflowResponse.class)).when(executor).superComplete(any());
This adds zero behavioral change to production code execute() still calls exactly what it would have called directly but creates a clean seam for testability without resorting to heavier tools like PowerMock just to stub a super.* call.
Photo by David Travis on Unsplash
11. The race condition hiding in your test suite
Here’s a genuinely subtle bug that cost real debugging time. Two specific tests one checking admin-URL derivation, one checking rejection-email body content failed intermittently. Not always. Just often enough to be deeply annoying, and exactly the kind of failure pattern that makes you start doubting your own test logic.
The root cause: javax.mail.Transport.send() is synchronous from the caller's point of view it blocks until the SMTP server acknowledges the DATA command. But Wiser's internal bookkeeping actually appending the fully-parsed message to its getMessages() list happens on a separate accept thread, slightly after that acknowledgment goes out. On a sufficiently fast machine, or under different thread-scheduling pressure (CI runners are a common trigger), the test's very next line reading wiser.getMessages()could execute before Wiser finished that append.
This is a textbook case of “the API call returning successfully” and “the receiving system having fully processed the result” being two different guarantees, even when the API looks fully synchronous.
The fix: poll for the expected message count instead of asserting immediately:
private void awaitMessageCount(int expectedCount, long timeoutMillis) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutMillis;
while (receivedMessages().size() < expectedCount && System.currentTimeMillis() < deadline) {
Thread.sleep(20);
}
}
private void withCarbonContextExpectingMessages(Runnable action, int expectedCount)
throws InterruptedException {
withCarbonContext(action);
awaitMessageCount(expectedCount, 2000);
}
Every test that immediately inspects a just-sent email now routes through withCarbonContextExpectingMessages() instead of the bare withCarbonContext(). Twenty milliseconds of polling overhead per check, in exchange for eliminating an entire category of flaky, hard-to-reproduce CI failures an easy trade.
12. The production bug: silent rejection emails
This is the centerpiece of this whole project, and it’s worth slowing down for, because it’s a genuinely instructive bug found not through a failing test, but through a single buried line in a real production server log:
ERROR - CustomUserSignUpWorkflowExecutor Error reading user email during workflow completion
org.wso2.carbon.user.core.UserStoreException: 30007 - UserNotFound:
User testaccount1 does not exist in: PRIMARY
at ...AbstractUserStoreManager.getUserClaimValue(...)
at CustomUserSignUpWorkflowExecutor.sendUserNotification(...)
at CustomUserSignUpWorkflowExecutor.complete(...)
What was actually happening
Recall from Section 2: execute() and complete() are genuinely separate invocations, potentially far apart in time. The original sendUserNotification() implementation performed a live claim lookup at complete() time:
private void sendUserNotification(String username, WorkflowStatus status) {
try {
PrivilegedCarbonContext ctx = PrivilegedCarbonContext.getThreadLocalCarbonContext();
UserStoreManager usm = ctx.getUserRealm().getUserStoreManager();
String userEmail = usm.getUserClaimValue(username, EMAIL_CLAIM_URI, null);
if (userEmail != null && !userEmail.isEmpty()) {
// ... send approval or rejection email
} else {
log.warn("No email claim found for user: " + username);
}
} catch (UserStoreException e) {
log.error("Error reading user email during workflow completion", e);
}
}
In production, WSO2 sometimes removes or invalidates a user’s account record by the time a rejection decision reaches complete() plausibly because a rejected signup gets cleaned up faster than an approved one in whatever automated housekeeping the deployment runs. When that happens, getUserClaimValue() throws UserStoreException, which lands in the catch block gets logged at ERROR level and the method just returns.
The rejection email was silently never sent. No exception bubbled up. No alert fired. Just a single log line that nobody was actively watching for, buried among thousands of other lines in a busy server log.
Sit with how bad this specific failure mode is: the one outcome where the applicant most needs a clear explanation being rejected was exactly the outcome statistically most likely to trigger this bug. The system was failing silently in precisely the scenario where silence does the most damage.
The fix: capture-then-fallback caching
The fix follows directly from the Section 2 lesson: if you need state that survives between two genuinely separate method invocations, it has to live somewhere external to either call. A simple in-memory cache, populated at the point where the data is guaranteed to still exist:
private static final ConcurrentMap<String, String> pendingUserEmailCache = new ConcurrentHashMap<>();
At Stage 1 (execute()), while the applicant's account is freshly created and definitely still exists, capture their email:
private void sendAdminAlert(String username) {
// ... existing lookup logic
String userEmail = usm.getUserClaimValue(username, EMAIL_CLAIM_URI, null);
if (userEmail != null && !userEmail.isEmpty()) {
pendingUserEmailCache.put(username, userEmail);
}
// ... continue sending the admin alert
}
At Stage 2 (complete()), try the live lookup first it still works for the common case (approvals, where the account is intact) and fall back to the cache only if that fails:
private void sendUserNotification(String username, WorkflowStatus status) {
String userEmail = null;
try {
PrivilegedCarbonContext ctx = PrivilegedCarbonContext.getThreadLocalCarbonContext();
UserStoreManager usm = ctx.getUserRealm().getUserStoreManager();
userEmail = usm.getUserClaimValue(username, EMAIL_CLAIM_URI, null);
} catch (UserStoreException e) {
log.warn("Could not look up live email claim for user: " + username
+ " (account may already be removed — falling back to cached email).");
}
if (userEmail == null || userEmail.isEmpty()) {
userEmail = pendingUserEmailCache.get(username);
if (userEmail != null) {
log.info("Using cached email captured at signup time for user: " + username);
}
}
pendingUserEmailCache.remove(username); // clean up - terminal state reached
if (userEmail != null && !userEmail.isEmpty()) {
if (WorkflowStatus.APPROVED.equals(status)) {
sendEmail(userEmail, "Your developer account has been approved",
getUserApprovedTemplate(username));
} else if (WorkflowStatus.REJECTED.equals(status)) {
sendEmail(userEmail, "An update on your registration request",
getUserRejectedTemplate(username));
}
} else {
log.warn("No email available (live or cached) for user: " + username
+ " - notification not sent.");
}
}
The cache entry is explicitly removed once the workflow reaches a terminal state, which matters for a static ConcurrentMap that otherwise has no natural eviction without this, a high-signup-volume deployment would slowly leak memory.
Locking the fix in with regression tests
A bug this subtle deserves a test that encodes exactly the scenario that caused it, so it can never silently regress:
@Test
@DisplayName("REGRESSION: rejection email still sent via cache when user record is already gone")
void rejectionEmailSentFromCacheWhenUserRecordAlreadyRemoved() throws Exception {
// Step 1: run execute() normally -- this populates the cache while
// the user record still genuinely exists.
WorkflowDTO executeDto = buildDto(TEST_USERNAME, WorkflowStatus.CREATED);
withCarbonContextExpectingMessages(() -> executor.execute(executeDto), 2);
wiser.getMessages().clear();
// Step 2: simulate the account being gone by the time complete() runs --
// exactly the real production stack trace.
when(userStoreManager.getUserClaimValue(eq(TEST_USERNAME), eq(EMAIL_CLAIM_URI), isNull()))
.thenThrow(new UserStoreException(
"30007 - UserNotFound: User " + TEST_USERNAME + " does not exist in: PRIMARY"));
WorkflowDTO completeDto = buildDto(TEST_USERNAME, WorkflowStatus.REJECTED);
withCarbonContextExpectingMessages(() -> executor.complete(completeDto), 1);
// The rejection email must still arrive, sourced from the cache.
assertEquals(1, receivedMessages().size(),
"rejection email should still be sent via the cached email fallback");
assertEquals(TEST_USER_EMAIL, receivedMessages().get(0).getEnvelopeReceiver());
}
Plus the companion edge case what if complete() runs for a user whose execute() was never seen by this process (a server restart between stages, for instance)? It must still degrade gracefully:
@Test
@DisplayName("REGRESSION: no email and no crash when user record is gone AND nothing was ever cached")
void noEmailWhenUserStoreThrowsAndCacheIsEmpty() throws Exception {
when(userStoreManager.getUserClaimValue(eq(TEST_USERNAME), eq(EMAIL_CLAIM_URI), isNull()))
.thenThrow(new UserStoreException("30007 - UserNotFound..."));
WorkflowDTO dto = buildDto(TEST_USERNAME, WorkflowStatus.REJECTED);
withCarbonContext(() -> assertDoesNotThrow(() -> executor.complete(dto)));
assertEquals(0, receivedMessages().size(),
"no email can be sent when neither a live claim nor a cached email is available");
}
The honest limitation
This cache is process-local and in-memory. It will not survive a WSO2 server restart occurring between a user’s signup and the admin’s eventual decision. For typical same-session approval flows this is a complete fix. For a deployment where approvals can realistically sit pending for days, the more durable version of this fix replaces the ConcurrentHashMap with a small persisted table a few extra columns on an existing table, or a dedicated one so the captured email survives process restarts too. Worth knowing the boundary of what you've actually fixed, not just that you fixed something.
13. Final project structure and what you’ve built
wso2-usersignup-workflow/
├── pom.xml
├── deploy.ps1
├── src/
│ ├── main/java/com/mycompany/custom/usersignup/
│ │ └── CustomUserSignUpWorkflowExecutor.java
│ └── test/java/com/mycompany/custom/usersignup/
│ └── CustomUserSignUpWorkflowExecutorTest.java (32 hermetic tests)
└── target/
└── com.mycompany.custom.usersignup.extension-1.0.0.jar
By this point you have:
- A correctly OSGi-packaged WSO2 APIM extension with zero external runtime dependencies
- Four branded HTML email templates sharing one design system
- A genuine XSS-hardening pass with a documented, tested rationale for every escaped character
- A 32-test hermetic suite that exercises real
MimeMessagecontent via an in-process SMTP server, with zero infrastructure dependencies for everyday CI runs - A documented and tested fix for a real production bug that silently dropped rejection emails — with regression tests that encode the exact failure scenario, so it can never quietly come back
14. Lessons that generalize beyond this project
A few things worth carrying forward into other projects, regardless of whether you ever touch WSO2 again:
“It compiled and the test passed” and “this is actually correct” are not the same claim. The race condition in Section 11 produced intermittent failures that had nothing to do with the production logic being wrong — it was a timing assumption baked into the test, not the code under test.
Mocking only the call you expect to make can hide real bugs. A mock that just verifies “was send() called?" would never have caught a malformed email body. Using a real (if lightweight) SMTP server for tests was strictly more valuable for the same amount of test-writing effort.
The worst bugs hide in the gap between two separate method invocations that look like one continuous flow. execute() and complete() feel like step 1 and step 2 of one operation when you're reading the code top to bottom but they're genuinely separate, separated by arbitrary time, possibly different threads, possibly a server restart in between. Any state implicitly assumed to "still be there" across that gap is a latent bug waiting for the right (wrong) timing.
Production logs catch bugs that unit tests, by construction, cannot. The rejection-email bug only existed because of a specific interaction between WSO2’s internal account-cleanup timing and this code’s lookup timing a real-world race condition between two systems that no amount of mocked-component unit testing would have surfaced. Watching actual server logs for ERROR-level lines that nobody’s actively triaging is still one of the highest-value debugging habits there is.
Security escaping needs a “why,” not just a list of characters. Adding = to the escape table closed a real gap. Adding backtick and forward-slash, in an earlier draft, closed nothing and actively broke URLs. Every character on an escape list should be traceable to a specific attack vector relevant to the actual rendering context copy-pasting a longer "more secure-looking" list from somewhere else is not a substitute for that reasoning.
Photo by Parrish Freeman on Unsplash
If you build something similar, or hit a different flavor of this same execute()/complete() state-sharing trap in your own WSO2 extensions, I’d genuinely like to hear about it these workflow extension points are flexible enough that everyone seems to discover a slightly different version of the same class of bug.
This article documents the real, iterative development of a working WSO2 APIM 4.2.0 extension, including bugs found through actual production server logs rather than hypothetical examples. Code samples are drawn directly from the working implementation.
About the Author
Nelush Gayashan Fernando is a Lead Software Engineer with expertise in full-stack development, specializing in Java, Spring Boot, ReactJS, and scalable microservices architecture. With a background spanning banking, enterprise software, and API management, he writes about data structures, backend engineering, and practical software development best practices.
If you found this article helpful, give it a clap 👏 and follow for more content on software engineering and backend development!
메타데이터
- post_id
- f0ce5644ca8d
- slug
- building-a-production-grade-custom-signup-workflow-for-wso2-api-manager-a-complete-tutorial-f0ce5644ca8d
- url
- https://medium.com/@nelushgayashan/building-a-production-grade-custom-signup-workflow-for-wso2-api-manager-a-complete-tutorial-f0ce5644ca8d
- canonical_url
- https://medium.com/@nelushgayashan/building-a-production-grade-custom-signup-workflow-for-wso2-api-manager-a-complete-tutorial-f0ce5644ca8d
- author_url
- https://medium.com/@nelushgayashan
- status
- ok
- fetched_at
- 2026-06-22 00:13:37