Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9ac5101
event and listners for webhook
cb-karthikp Nov 24, 2025
e396c2e
update type
cb-karthikp Dec 1, 2025
a4a3ba4
include pc1 events
cb-karthikp Dec 1, 2025
b6810d9
node webhook hbs changes
cb-karthikp Dec 1, 2025
7419c12
add default webhook handler instance
cb-karthikp Dec 4, 2025
583508f
Update index.d.ts.hbs
cb-karthikp Dec 9, 2025
5b29303
Event type class name change
cb-karthikp Dec 10, 2025
93f57bf
add Event type run-time export
cb-karthikp Dec 16, 2025
df23d80
moved un-handled out
cb-karthikp Dec 17, 2025
24544c5
framwork agnostic request-response type
cb-karthikp Dec 24, 2025
64c417c
Merge branch 'main' into node-webhook-handler
cb-karthikp Jan 21, 2026
76d5496
SDK changes
cb-karthikp Feb 4, 2026
ec368d3
move default auth validation to util
cb-karthikp Feb 4, 2026
6696142
add warning for no-auth webhook flow
cb-karthikp Feb 4, 2026
24e07ef
add field validation
cb-karthikp Feb 4, 2026
46822f1
better error management
cb-karthikp Feb 4, 2026
b69976a
add deprecation message and strict-content type
cb-karthikp Feb 4, 2026
0940ac9
add comments
cb-karthikp Feb 4, 2026
c4b6050
Merge branch 'main' into node-webhook-handler
cb-karthikp Feb 4, 2026
a545d0a
Update test case
cb-karthikp Feb 5, 2026
9087f91
Merge branch 'main' into node-webhook-handler
cb-karthikp Feb 5, 2026
fc49503
fix testcase
cb-karthikp Feb 5, 2026
a2b132e
Fixed an issue with the webhook content for hidden resources.
cb-alish Feb 5, 2026
228ce2e
add support on error.
cb-karthikp Feb 11, 2026
0f74017
Merge branch 'main' into node-webhook-handler
cb-karthikp Feb 11, 2026
90cf45e
Fix TypeScriptTypingV3Tests for updated webhook
cb-karthikp Feb 11, 2026
bc4769d
Webhook error changes
cb-karthikp Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/main/java/com/chargebee/handlebar/NameFormatHelpers.java
Original file line number Diff line number Diff line change
Expand Up @@ -146,5 +146,12 @@ public CharSequence apply(final Object value, final Options options) {
}
return result.toString();
}
},

CONSTANT_CASE {
@Override
public CharSequence apply(final Object value, final Options options) {
return value.toString().toUpperCase().replace("-", "_");
}
}
}
1 change: 1 addition & 0 deletions src/main/java/com/chargebee/sdk/Language.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ private void initialise() throws IOException {
handlebars.registerHelper("pascalCase", NameFormatHelpers.TO_PASCAL);
handlebars.registerHelper(
"operationNameToPascalCase", NameFormatHelpers.OPERATION_NAME_TO_PASCAL_CASE);
handlebars.registerHelper("constantCase", NameFormatHelpers.CONSTANT_CASE);

handlebars.registerHelper(
"snakeCaseToPascalCaseAndSingularize",
Expand Down
34 changes: 31 additions & 3 deletions src/main/java/com/chargebee/sdk/node/NodeV3.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.chargebee.openapi.Spec;
import com.chargebee.sdk.FileOp;
import com.chargebee.sdk.Language;
import com.chargebee.sdk.node.webhook.WebhookGenerator;
import com.github.jknack.handlebars.Template;
import java.io.IOException;
import java.util.*;
Expand All @@ -19,19 +20,46 @@ protected List<FileOp> generateSDK(String outputDirectoryPath, Spec spec) throws
.filter(resource -> !Arrays.stream(this.hiddenOverride).toList().contains(resource.id))
.sorted(Comparator.comparing(Resource::sortOrder))
.toList();
return List.of(generateApiEndpointsFile(outputDirectoryPath, resources));
List<FileOp> fileOps = new ArrayList<>();
fileOps.add(generateApiEndpointsFile(outputDirectoryPath, resources));

// Generate webhook files (event types, content, handler)
{
Template eventTypesTemplate = getTemplateContent("webhookEventTypes");
Template contentTemplate = getTemplateContent("webhookContent");
Template handlerTemplate = getTemplateContent("webhookHandler");
Template authTemplate = getTemplateContent("webhookAuth");
fileOps.addAll(
WebhookGenerator.generate(
outputDirectoryPath,
spec,
eventTypesTemplate,
contentTemplate,
handlerTemplate,
authTemplate
)
);
}

return fileOps;
}

@Override
protected Map<String, String> templatesDefinition() {
return Map.of("api_endpoints", "/templates/node/api_endpoints.ts.hbs");
return Map.of(
"api_endpoints", "/templates/node/api_endpoints.ts.hbs",
"webhookEventTypes", "/templates/node/webhook_event_types.ts.hbs",
"webhookContent", "/templates/node/webhook_content.ts.hbs",
"webhookHandler", "/templates/node/webhook_handler.ts.hbs",
"webhookAuth", "/templates/node/webhook_auth.ts.hbs"
);
}

private FileOp generateApiEndpointsFile(String resourcesDirectoryPath, List<Resource> resources)
throws IOException {
List<Map<String, Object>> resourcesMap =
resources.stream().map(resource -> resource.templateParams(this)).toList();
Map templateParams = Map.of("resources", resourcesMap);
Map<String, Object> templateParams = Map.of("resources", resourcesMap);
Template resourceTemplate = getTemplateContent("api_endpoints");
return new FileOp.WriteString(
resourcesDirectoryPath, "api_endpoints.ts", resourceTemplate.apply(templateParams));
Expand Down
152 changes: 152 additions & 0 deletions src/main/java/com/chargebee/sdk/node/webhook/WebhookGenerator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.chargebee.sdk.node.webhook;

import com.chargebee.openapi.Attribute;
import com.chargebee.openapi.Resource;
import com.chargebee.openapi.Spec;
import com.chargebee.sdk.FileOp;
import com.github.jknack.handlebars.Template;
import java.io.IOException;
import java.util.*;

public class WebhookGenerator {

private static List<String> getEventResourcesForAEvent(Resource eventResource) {
List<String> resources = new ArrayList<>();
if (eventResource != null) {
for (Attribute attribute : eventResource.attributes()) {
if (attribute.name.equals("content")) {
attribute
.attributes()
.forEach(
(innerAttribute -> {
String ref = innerAttribute.schema.get$ref();
if (ref != null && ref.contains("/")) {
String schemaName = ref.substring(ref.lastIndexOf("/") + 1);
resources.add(schemaName);
}
}));
}
}
}
return resources;
}

public static List<FileOp> generate(
String outputDirectoryPath,
Spec spec,
Template eventTypesTemplate,
Template contentTemplate,
Template handlerTemplate,
Template authTemplate)
throws IOException {
final String webhookDirectoryPath = "/webhook";
List<FileOp> fileOps = new ArrayList<>();
// Ensure webhook directory exists
fileOps.add(new FileOp.CreateDirectory(outputDirectoryPath, webhookDirectoryPath));

// Include deprecated webhook events (like PCV1) since customers may still receive them
var webhookInfo = spec.extractWebhookInfo(true);
var eventSchema = spec.resourcesForEvents();

if (webhookInfo.isEmpty()) {
return fileOps;
}

List<Map<String, Object>> events = new ArrayList<>();
Set<String> seenTypes = new HashSet<>();
Set<String> uniqueImports = new HashSet<>();

// Compute models directory by taking parent of webhook output dir
java.io.File webhookDir = new java.io.File(outputDirectoryPath + webhookDirectoryPath);
java.io.File chargebeeRoot = webhookDir.getParentFile();

for (Map<String, String> info : webhookInfo) {
String type = info.get("type");
if (seenTypes.contains(type)) {
continue;
}
seenTypes.add(type);

String resourceSchemaName = info.get("resource_schema_name");
Resource matchedSchema =
eventSchema.stream()
.filter(schema -> schema.name.equals(resourceSchemaName))
.findFirst()
.orElse(null);

List<String> allSchemas = getEventResourcesForAEvent(matchedSchema);
List<String> schemaImports = new ArrayList<>();

for(String schema : allSchemas) {
// In Node we import Resource classes/interfaces.
// Assuming 'Customer' -> 'Customer' in types
schemaImports.add(schema);
uniqueImports.add(schema);
}

Map<String, Object> params = new HashMap<>();
params.put("type", type);
params.put("resource_schemas", schemaImports);
events.add(params);
}

events.sort(Comparator.comparing(e -> e.get("type").toString()));

// event_types.ts
{
Map<String, Object> ctx = new HashMap<>();
ctx.put("events", events);
fileOps.add(
new FileOp.WriteString(
outputDirectoryPath + webhookDirectoryPath,
"event_types.ts",
eventTypesTemplate.apply(ctx)
)
);
}

// content.ts
{
Map<String, Object> ctx = new HashMap<>();
ctx.put("events", events);
List<String> importsList = new ArrayList<>(uniqueImports);
Collections.sort(importsList);
ctx.put("unique_imports", importsList);

fileOps.add(
new FileOp.WriteString(
outputDirectoryPath + webhookDirectoryPath,
"content.ts",
contentTemplate.apply(ctx)
)
);
}

// handler.ts
{
Map<String, Object> ctx = new HashMap<>();
ctx.put("events", events);
fileOps.add(
new FileOp.WriteString(
outputDirectoryPath + webhookDirectoryPath,
"handler.ts",
handlerTemplate.apply(ctx)
)
);
}

// auth.ts
{
fileOps.add(
new FileOp.WriteString(
outputDirectoryPath + webhookDirectoryPath,
"auth.ts",
authTemplate.apply("")
)
);
}

return fileOps;
}
}

29 changes: 29 additions & 0 deletions src/main/resources/templates/node/webhook_auth.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export const basicAuthValidator = (validateCredentials: (username: string, password: string) => boolean) => {
return (headers: Record<string, string | string[] | undefined>) => {
const authHeader = headers['authorization'] || headers['Authorization'];

if (!authHeader) {
throw new Error("Invalid authorization header");
}

const authStr = Array.isArray(authHeader) ? authHeader[0] : authHeader;
if (!authStr) {
throw new Error("Invalid authorization header");
}

const parts = authStr.split(' ');
if (parts.length !== 2 || parts[0] !== 'Basic') {
throw new Error("Invalid authorization header");
}

const credentials = Buffer.from(parts[1], 'base64').toString().split(':');
if (credentials.length !== 2) {
throw new Error("Invalid credentials");
}

if (!validateCredentials(credentials[0], credentials[1])) {
throw new Error("Invalid credentials");
}
};
};

27 changes: 27 additions & 0 deletions src/main/resources/templates/node/webhook_content.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
declare module 'chargebee' {
{{#each unique_imports}}
export interface {{this}} {}
{{/each}}
}

{{#each events}}
export interface {{snakeCaseToPascalCase type}}Content {
{{#each resource_schemas}}
{{camelCase this}}: import('chargebee').{{this}};
{{/each}}
}

{{/each}}
export interface WebhookEvent {
id: string;
occurred_at: number;
source: string;
user?: string;
webhook_status: string;
webhook_failure_reason?: string;
webhooks?: any[];
event_type: string;
api_version: string;
content: any;
}

6 changes: 6 additions & 0 deletions src/main/resources/templates/node/webhook_event_types.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum EventType {
{{#each events}}
{{constantCase type}} = '{{type}}',
{{/each}}
}

86 changes: 86 additions & 0 deletions src/main/resources/templates/node/webhook_handler.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { EventType } from './event_types.js';
import {
{{#each events}}
{{snakeCaseToPascalCase type}}Content,
{{/each}}
WebhookEvent
} from './content.js';

export interface WebhookHandlers {
{{#each events}}
on{{snakeCaseToPascalCase type}}?: (event: WebhookEvent & { content: {{snakeCaseToPascalCase type}}Content }) => Promise<void>;
{{/each}}
}

export class WebhookHandler {
private _handlers: WebhookHandlers = {};

/**
* Optional callback for unhandled events.
*/
onUnhandledEvent?: (event: WebhookEvent) => Promise<void>;

/**
* Optional callback for errors during processing.
*/
onError?: (error: any) => void;

/**
* Optional validator for request headers.
*/
requestValidator?: (headers: Record<string, string | string[] | undefined>) => void;

constructor(handlers: WebhookHandlers = {}) {
this._handlers = handlers;
}

{{#each events}}
set on{{snakeCaseToPascalCase type}}(handler: ((event: WebhookEvent & { content: {{snakeCaseToPascalCase type}}Content }) => Promise<void>) | undefined) {
this._handlers.on{{snakeCaseToPascalCase type}} = handler;
}

get on{{snakeCaseToPascalCase type}}() {
return this._handlers.on{{snakeCaseToPascalCase type}};
}

{{/each}}

async handle(body: string | object, headers?: Record<string, string | string[] | undefined>): Promise<void> {
try {
if (this.requestValidator && headers) {
this.requestValidator(headers);
}

let event: WebhookEvent;
if (typeof body === 'string') {
event = JSON.parse(body);
} else {
event = body as WebhookEvent;
}

const eventType = event.event_type;

switch (eventType) {
{{#each events}}
case EventType.{{constantCase type}}:
if (this._handlers.on{{snakeCaseToPascalCase type}}) {
await this._handlers.on{{snakeCaseToPascalCase type}}(event as WebhookEvent & { content: {{snakeCaseToPascalCase type}}Content });
return;
}
break;
{{/each}}
}

if (this.onUnhandledEvent) {
await this.onUnhandledEvent(event);
}
} catch (err) {
if (this.onError) {
this.onError(err);
} else {
throw err;
}
}
}
}