Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This repo includes examples for:
- [Deny plan cancellation](https://github.com/kinde-starter-kits/workflow-examples/blob/main/planCancellationRequest/denyPlanCancellation.ts) - Prevent a user from cancelling their plan. Useful if you need to do manual deprovisioning
- [Check IP with AbuseIPDB](https://github.com/kinde-starter-kits/workflow-examples/blob/main/postUserAuthentication/checkIPWithAbuseIPDBWorkflow.ts) - Checks IP of user logging in with AbuseIPDB and blocks login if abuse confidence rating is too high.
- [Include billing info in user tokens](https://github.com/kinde-starter-kits/workflow-examples/blob/main/userTokens/addBillingDetailsToTokensB2C.ts) - Fetches a user’s billing information during token generation and adds it to the user tokens as a billingDetails custom claim.
- [Track Organization seat usage](https://github.com/kinde-starter-kits/workflow-examples/blob/main/billing/trackOrgSeatUsageWorkflow.ts) - Updates metered seat usage for B2B SaaS organizations in Kinde when a new user joins. Ensures accurate per-user billing by incrementing the 'user' feature count after authentication.

Each example includes:

Expand Down
146 changes: 146 additions & 0 deletions billing/trackOrgSeatUsageWorkflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
onPostAuthenticationEvent,
WorkflowSettings,
WorkflowTrigger,
createKindeAPI,
} from "@kinde/infrastructure";

/**
* Workflow: Track Per-User (Seat-Based) Billing Usage in Kinde
*
* This workflow is designed for a standard B2B SaaS setup in Kinde, where:
* - Organizations are billed per active user (seat-based pricing)
* - Billing is managed by organization administrators
* - Users can join organizations via orgCode, allowed domains, or custom invite flows
*
* This workflow should be triggered after user authentication (PostAuthentication event).
* It ensures that whenever a new user is added to an organization, the metered usage for the
* 'user' feature is updated for accurate seat-based billing.
*
* Prerequisites:
* 1. Connect your Stripe account in the Kinde dashboard.
* 2. Create and publish a per-user (seat-based) billing plan with a metered feature key 'user'.
* 3. Assign the Billing Admin role to organization creators.
* 4. Enable organization creation and joining via orgCode or allowed domains.
* 5. Set up a Kinde M2M application with the following scopes:
* - read:organizations
* - create:meter_usage
* 6. Add the following environment variables in Kinde:
* - KINDE_WF_M2M_CLIENT_ID
* - KINDE_WF_M2M_CLIENT_SECRET (set as sensitive)
*
* Usage:
* - This workflow should be used to report seat usage whenever a user is added to an organization.
* - It can be extended to handle removals or scheduled reconciliation jobs for true-up billing.
*
* For more details, see the Kinde B2B SaaS billing guide.
*/

export const workflowSettings: WorkflowSettings = {
id: "trackOrgSeatUsage",
name: "Track Organization Seat Usage",
failurePolicy: {
action: "stop",
},
trigger: WorkflowTrigger.PostAuthentication,
bindings: {
"kinde.env": {},
"kinde.fetch": {},
url: {},
},
};

// The workflow code to be executed when the event is triggered
/**
* PostAuthentication workflow handler to track seat usage for billing.
*
* Triggered when a user is added to the Kinde user pool for the first time (isNewUserRecordCreated).
* Looks up the organization and plan, and updates metered usage for the 'user' feature.
*/
export default async function trackOrgSeatUsage(event: onPostAuthenticationEvent) {
// Use optional chaining to safely access nested properties with sensible defaults
const isNewKindeUser = event?.context?.auth?.isNewUserRecordCreated ?? false;
const orgCode = event?.request?.authUrlParams?.orgCode;

console.log('[DEBUG] orgCode from authUrlParams:', orgCode);
console.log('[DEBUG] isNewKindeUser:', isNewKindeUser);

// Early return if required properties are missing
if (!orgCode || !event?.context?.user?.id) {
console.log('[DEBUG] Missing required parameters (orgCode or user ID). Exiting workflow safely.');
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +69 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent functional gap: domain-based and admin-invited users are never counted. The guard if (!orgCode || ...) combined with orgCode being sourced exclusively from event?.request?.authUrlParams?.orgCode means this workflow only fires for users who join via the org-code self-signup URL flow.

Two other first-party Kinde join patterns are silently skipped:

  1. Domain-based auto-add - users auto-joined because their email matches an org's allowed domain. No orgCode appears in the auth URL params for this flow.
  2. Admin-invited / manually added users - users added by an org admin through the Kinde dashboard or API.

In both cases orgCode is undefined, the workflow hits this early-return, and the seat is never counted. For any B2B org that uses either of these flows, billing will systematically under-report seats.

The JSDoc at the top states the workflow tracks usage "whenever a new user joins an organization" - that claim is currently only true for the org-code flow.

You could either:

  • If the PostAuthentication event exposes event.context.organization?.code or similar, derive orgCode from context rather than authUrlParams to cover all join flows universally.
  • If org context is not available in the event, add an explicit "Known Limitations" section to the JSDoc block clearly stating that only the org-code self-signup flow is currently supported, and that domain-based and admin-added users require a separate reconciliation workflow.


// Only update usage if this is a new user record
if (!isNewKindeUser) {
console.log('[DEBUG] User is not new. No seat usage update needed. Exiting workflow safely.');
return;
}

const kindeUserId = event.context.user.id;
console.log('[DEBUG] New Kinde user ID:', kindeUserId);

// Create Kinde Management API client
const kindeAPI = await createKindeAPI(event);
console.log('[DEBUG] Kinde API client created');

// Fetch organization details (including billing info)
const orgResponse = await kindeAPI.get({
endpoint: `organization?code=${orgCode}&expand=billing`,
});
Comment on lines +88 to +90
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded async API call (org fetch) is an issue I think. This await kindeAPI.get(...) has no surrounding try/catch. A rejected Promise here (e.g. M2M token missing read:organizations scope, invalid orgCode, transient network error, or a 403/404 from the Kinde API) will escape the async boundary as an unhandled rejection. The failurePolicy: { action: "stop" } defined in workflowSettings is designed to catch explicit throw from inside the handler - it may not intercept an unhandled rejection that escapes the async function without being explicitly re-thrown.

Additionally, immediately after this call, orgResponse.data is accessed at line 93 with no guard on orgResponse itself. An error-shaped API response (non-throwing, but with no .data property) would produce a silent TypeError at line 93.

Required fix: Wrap this call in a try/catch. On error, log a structured message including orgCode and the error, then return (or throw if you want the failure policy to trigger). After the call, guard if (!orgResponse?.data) before accessing .data.

The reference implementation for this pattern in this repo is userTokens/addBillingDetailsToTokensB2C.ts, which uses a single top-level try/catch around the entire handler body.

Note: As far as I can tell this was flagged as Major by CodeRabbit in two consecutive review passes (threads on commits b97ae0e and 3ec4fde) and marked resolved on GitHub, but the fix was not committed. Please treat it as unresolved.

console.log('[DEBUG] orgResponse:', orgResponse);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Production debug logs expose sensitive billing identifiers. That's blocking. This line logs the raw orgResponse object, which may contain full billing metadata returned by the Kinde API. Further down, lines 94, 110, and 120 similarly log the full organization object, the raw agreement object, and billingCustomerAgreementId respectively. Billing agreement IDs are sensitive financial identifiers - logging them in plain text in the workflow runtime log stream creates an audit trail and potential information-disclosure risk.

The [DEBUG] prefix signals these were developer-aid logs added during development. They should be removed before merge, not shipped to production.

Looking at the other workflows in this repo (syncNewUserToHubspotWorkflow.ts, impossibleTravelWorkflow.ts), production-ready logs use console.info/console.error with specifically selected safe fields only (e.g., { userId, orgCode }) - never raw API response objects.

My thought: Remove all [DEBUG]-tagged console.log calls. If you want to retain operational observability, keep at most two console.info checkpoints with only safe, non-sensitive fields: one confirming the org was found (log orgCode only), and one confirming the meter was incremented (log orgCode + kindeUserId only). Never log agreement objects or agreement IDs.


const organization = orgResponse.data;
console.log('[DEBUG] organization:', organization);
const planCode = "standard-organization-plan"; // Update if your plan code differs
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded magic string; should be an environment variable. "standard-organization-plan" is inlined directly in the function body. Every adopter of this workflow example must remember to find and edit a string buried inside the function - a footgun that's easy to miss. This is especially problematic because it's a billing plan code: getting it wrong means the agreement lookup silently finds nothing and returns early without incrementing usage.

The kinde.env binding is already declared in workflowSettings, making this a zero-cost change. The prerequisite doc block already instructs users to set up env vars - adding one more is a natural fit.

Suggested approach: Read the plan code via getEnvironmentVariable("KINDE_WF_BILLING_PLAN_CODE")?.value, with a fallback to the current string as a default. Add KINDE_WF_BILLING_PLAN_CODE to the env var list in the JSDoc prerequisite section.

console.log('[DEBUG] planCode:', planCode);

// Ensure billing data exists
if (!organization.billing || !organization.billing.agreements || organization.billing.agreements.length === 0) {
console.log(
`[INFO] Organization ${orgCode} does not have billing configured or no agreements found. Skipping metered usage update.`
);
return;
}

// Find the correct billing agreement for the plan
const agreement = organization.billing.agreements.find(
(agr: any) => agr.plan_code === planCode
);
Comment on lines +107 to +109
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weak typing: any escape hatch on billing agreement. The .find((agr: any) => ...) callback uses an explicit any type, which disables TS type-checking for the agreement shape. This means the agreement.agreement_id access on line 119 is completely unchecked at compile time - if the Kinde API ever returns a different field name (e.g., id instead of agreement_id), or if the agreements array contains an unexpected shape, this fails silently at runtime with no compile-time signal.

Compare to userTokens/addBillingDetailsToTokensB2C.ts in this same repo, which defines full TS interfaces (Agreement, AgreementsResponse, BillingClaim) for all API response shapes and uses generic typed calls like kindeAPI.get<UserResponse>(...).

What I would suggest is to define a BillingAgreement interface with at minimum { plan_code: string; agreement_id: string } properties. Use this type in the .find() callback and for the agreement variable, replacing the any.

console.log('[DEBUG] agreement:', agreement);

if (!agreement) {
console.log(
`[INFO] Organization ${orgCode} is not on plan ${planCode}. Skipping metered usage update.`
);
return;
}

const billingCustomerAgreementId = agreement.agreement_id;
console.log('[DEBUG] billingCustomerAgreementId:', billingCustomerAgreementId);

const billingFeatureCode = "user"; // Must match your metered feature key
console.log('[DEBUG] billingFeatureCode:', billingFeatureCode);

// Update metered usage for the organization (increment seat count)
console.log('[DEBUG] Posting metered usage update', {
customer_agreement_id: billingCustomerAgreementId,
billing_feature_code: billingFeatureCode,
meter_value: "1",
meter_type_code: "delta",
});
const meterUsageResponse = await kindeAPI.post({
endpoint: `billing/meter_usage`,
params: {
customer_agreement_id: billingCustomerAgreementId,
billing_feature_code: billingFeatureCode,
meter_value: "1",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meter_value sent as string "1" instead of number 1. Should this be typed as string or number?
Same would apply to meter_type_code

meter_type_code: "delta",
},
});
Comment on lines +132 to +140
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unguarded async API call (meter usage POST). This is the core operation of the entire workflow - the actual billing meter increment - and it has no error handling whatsoever. Failure modes include: an invalid or stale billingCustomerAgreementId, API rate limiting, a billing service outage, or a mismatched meter_type_code. In all of these cases the failure is completely silent: no log, no throw, no signal to Kinde runtime that the critical operation failed. Seat usage goes untracked, billing becomes inaccurate, and the operator has zero visibility.

I suggest to wrap this block in a try/catch. The catch block should log a structured error including orgCode, kindeUserId, billingCustomerAgreementId, and the caught error, then throw error to re-throw - this is essential for the failurePolicy: "stop" to engage and halt the workflow on billing failure.

Note: This was also flagged as Major by CodeRabbit and ghost-resolved without a code change.

console.log('[DEBUG] meterUsageResponse:', meterUsageResponse);

console.log(
`[INFO] Metered usage updated for organization ${orgCode} and user ${kindeUserId}`
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newline at end of file. The file ends without a trailing newline (\ No newline at end of file in the diff). Every other workflow file in this repo ends with a proper POSIX-compliant trailing newline. This causes noisy one-line diffs any time a future contributor edits the file.

Fix: Add a single newline character after the closing } on this line.