Skip to content

add: track org seat usage workflow#24

Open
Arobce wants to merge 3 commits into
kinde-starter-kits:mainfrom
Arobce:main
Open

add: track org seat usage workflow#24
Arobce wants to merge 3 commits into
kinde-starter-kits:mainfrom
Arobce:main

Conversation

@Arobce
Copy link
Copy Markdown
Contributor

@Arobce Arobce commented Jan 15, 2026

Explain your changes

Adds a workflow to track per-user (seat-based) billing usage in Kinde. This workflow updates the metered usage for the number of users tracking feature whenever a new user joins an organization, ensuring accurate seat-based billing for B2B SaaS setups. Triggered after user authentication, it integrates with Kinde’s billing and organization APIs.

Checklist

✅ I have read the “Pull requests” section in the contributing guidelines.

✅ I agree to the terms within the code of conduct.

Summary by CodeRabbit

  • New Features

    • Automatic seat-based usage tracking for B2B organizations: billed "user" count is incremented when new members join.
  • Documentation

    • Added example and guidance for enabling organization seat usage tracking in Kinde.

✏️ Tip: You can customize this high-level summary in your review settings.

@Arobce Arobce requested a review from a team as a code owner January 15, 2026 17:12
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 15, 2026

📝 Walkthrough

Walkthrough

Adds a new post-authentication workflow that increments metered seat usage for organizations in Kinde when a new user authenticates, and adds a README example describing this B2B seat-tracking use case.

Changes

Cohort / File(s) Summary
Documentation
README.md
Added example describing tracking organization seat usage for B2B SaaS in Kinde.
Billing workflow
billing/trackOrgSeatUsageWorkflow.ts
New workflow: exports workflowSettings and default trackOrgSeatUsage handler. Runs on PostAuthentication for new users, reads orgCode from auth URL params, retrieves organization billing and plan (standard-organization-plan), validates agreement, and posts a metered usage increment for the user feature with debug logging and guards for missing data.

Sequence Diagram

sequenceDiagram
    participant User
    participant KindeAuth as Kinde Auth Service
    participant Workflow as trackOrgSeatUsage
    participant KindeAPI as Kinde Admin API
    participant Billing as Billing Endpoint

    User->>KindeAuth: Authenticate (with orgCode in URL)
    KindeAuth->>Workflow: Trigger PostAuthentication event
    Workflow->>Workflow: Verify isNewUser & extract orgCode
    Workflow->>KindeAPI: GET /organizations?code=orgCode&include=billing
    KindeAPI-->>Workflow: Organization + billing plans
    Workflow->>KindeAPI: GET /agreements?organization=...&plan=standard-organization-plan
    KindeAPI-->>Workflow: Billing agreement
    Workflow->>Billing: POST /meter_usage { feature: "user", delta: 1 }
    Billing-->>Workflow: Confirmation
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • onderay
  • DanielRivers

Poem

🐰 I hopped through auth and found the key,

Org codes counted, seats ticked by me.
One tiny ping, a meter grows,
Billing hums where the user goes.
Hop on, deploy — the ledger shows!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a workflow to track organization seat usage, which aligns with the primary content of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings


📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b97ae0e and 3ec4fde.

📒 Files selected for processing (1)
  • billing/trackOrgSeatUsageWorkflow.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • billing/trackOrgSeatUsageWorkflow.ts

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@billing/trackOrgSeatUsageWorkflow.ts`:
- Line 61: The debug log in trackOrgSeatUsage (the console.log line that prints
"[DEBUG] trackOrgSeatUsage triggered" with the full event object) must not emit
the entire event; change it to log only a minimal, non-sensitive subset or
derived values (e.g., event.type, event.orgId, event.timestamp) or a
masked/validated payload, and remove tokens/PII before logging; update the
console.log call in trackOrgSeatUsage to explicitly select and/or sanitize those
safe fields instead of serializing the whole event.
- Around line 82-96: The code assumes organization.billing.agreements exists and
will throw if billing is missing; before calling find on
organization.billing.agreements (after orgResponse.data is read into
organization and after planCode is set) add a defensive guard: check orgResponse
and organization are defined and that organization.billing and
organization.billing.agreements are present (or use optional chaining like
organization?.billing?.agreements) and handle the missing case (log an
informative message and return or set agreement to undefined) so the subsequent
use of agreement is safe; update the block around kindeAPI.get, organization,
planCode, and the agreement lookup to include these null checks.
🧹 Nitpick comments (2)
billing/trackOrgSeatUsageWorkflow.ts (2)

118-127: Consider adding error handling for the billing API call.

The metered usage POST request may fail (e.g., network issues, invalid agreement ID, API rate limits). Without error handling, failures will be silent or cause unhandled promise rejections.

Proposed improvement
-        const meterUsageResponse = await kindeAPI.post({
-            endpoint: `billing/meter_usage`,
-            params: {
-                customer_agreement_id: billingCustomerAgreementId,
-                billing_feature_code: billingFeatureCode,
-                meter_value: "1",
-                meter_type_code: "delta",
-            },
-        });
-        console.log('[DEBUG] meterUsageResponse:', meterUsageResponse);
+        try {
+            const meterUsageResponse = await kindeAPI.post({
+                endpoint: `billing/meter_usage`,
+                params: {
+                    customer_agreement_id: billingCustomerAgreementId,
+                    billing_feature_code: billingFeatureCode,
+                    meter_value: "1",
+                    meter_type_code: "delta",
+                },
+            });
+            console.log('[DEBUG] meterUsageResponse:', meterUsageResponse);
+        } catch (error) {
+            console.error(`[ERROR] Failed to update metered usage for organization ${orgCode}:`, error);
+            throw error; // Re-throw to trigger failurePolicy
+        }

82-84: Verify error handling for organization fetch.

The organization API call could fail if the orgCode is invalid or the M2M credentials lack proper scopes. Consider wrapping in try-catch or validating the response status.

Proposed improvement
-        const orgResponse = await kindeAPI.get({
-            endpoint: `organization?code=${orgCode}&expand=billing`,
-        });
-        console.log('[DEBUG] orgResponse:', orgResponse);
+        let orgResponse;
+        try {
+            orgResponse = await kindeAPI.get({
+                endpoint: `organization?code=${orgCode}&expand=billing`,
+            });
+            console.log('[DEBUG] orgResponse:', orgResponse);
+        } catch (error) {
+            console.error(`[ERROR] Failed to fetch organization ${orgCode}:`, error);
+            return;
+        }
+
+        if (!orgResponse?.data) {
+            console.log(`[INFO] Organization ${orgCode} not found. Skipping metered usage update.`);
+            return;
+        }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e12a855 and 120ccbc.

📒 Files selected for processing (2)
  • README.md
  • billing/trackOrgSeatUsageWorkflow.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T20:09:27.413Z
Learnt from: ahn-nath
Repo: kinde-starter-kits/workflow-examples PR: 23
File: userTokens/addBillingDetailsToTokensB2BWorkflow.ts:35-76
Timestamp: 2025-12-12T20:09:27.413Z
Learning: In TypeScript workflow files within the project (e.g., userTokens/addBillingDetailsToTokensB2BWorkflow.ts), use the field billing_customer_id as the customer identifier when interacting with the Kinde API. Do not reference customer_id. If any code previously uses customer_id, update it to billing_customer_id and verify downstream mappings, typings, and API requests align with the new field name.

Applied to files:

  • billing/trackOrgSeatUsageWorkflow.ts
🔇 Additional comments (4)
billing/trackOrgSeatUsageWorkflow.ts (3)

1-6: LGTM!

Imports are appropriate for a PostAuthentication workflow using the Kinde infrastructure library.


39-51: LGTM!

Workflow settings are well-configured with appropriate bindings for environment variables, fetch capability, and URL access needed for API interactions.


8-37: Well-documented workflow with clear prerequisites.

The docblock provides excellent context including prerequisites, required scopes, and environment variables. This is helpful for users implementing the workflow.

README.md (1)

47-47: LGTM!

Documentation accurately describes the new workflow's purpose and behavior. The link path matches the file location.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment thread billing/trackOrgSeatUsageWorkflow.ts Outdated
Comment thread billing/trackOrgSeatUsageWorkflow.ts Outdated
@Arobce
Copy link
Copy Markdown
Contributor Author

Arobce commented Jan 15, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@billing/trackOrgSeatUsageWorkflow.ts`:
- Around line 126-139: The metered usage call using kindeAPI.post (creating
meterUsageResponse) lacks error handling and response validation; wrap the call
to kindeAPI.post({ endpoint: 'billing/meter_usage', ... }) in a try-catch, check
the returned meterUsageResponse for success (e.g., status or expected body
fields) and handle non-success by logging a descriptive error including
billingCustomerAgreementId, billingFeatureCode, orgCode, kindeUserId and the raw
response, and either throw or return a clearly-failed result so the workflow
surface knows the update failed; ensure console/log messages are replaced with
structured error/info logs and include the caught exception details.
- Around line 82-88: The kindeAPI.get call lacks error handling and the code
assumes orgResponse.data exists; wrap the API call to kindeAPI.get in a
try/catch, validate the returned response (check for error status and that
orgResponse && orgResponse.data && orgResponse.data.id or billing exists) before
assigning organization, and handle failures by logging the error and returning
or throwing early to avoid using undefined organization; specifically update the
usage around kindeAPI.get, orgResponse, and organization (and any downstream
logic that uses orgCode) to perform these checks and fail-fast with a clear
error/log message.
- Around line 60-70: In trackOrgSeatUsage, avoid direct nested access of
event.context.auth.isNewUserRecordCreated and
event.request.authUrlParams.orgCode; use optional chaining (e.g.,
event?.context?.auth?.isNewUserRecordCreated and
event?.request?.authUrlParams?.orgCode) and provide sensible defaults or
early-return if those values are undefined so the function doesn't throw
TypeError when intermediate properties are missing.
🧹 Nitpick comments (1)
billing/trackOrgSeatUsageWorkflow.ts (1)

89-90: Consider making planCode configurable.

The hardcoded plan code limits reusability. Consider using an environment variable to make this workflow adaptable across different deployments.

Suggested improvement
-        const planCode = "standard-organization-plan"; // Update if your plan code differs
+        const planCode = kinde.env.BILLING_PLAN_CODE || "standard-organization-plan";
         console.log('[DEBUG] planCode:', planCode);

This would require adding BILLING_PLAN_CODE to the environment variables documentation in the comment block.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 120ccbc and b97ae0e.

📒 Files selected for processing (1)
  • billing/trackOrgSeatUsageWorkflow.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-12T20:09:27.413Z
Learnt from: ahn-nath
Repo: kinde-starter-kits/workflow-examples PR: 23
File: userTokens/addBillingDetailsToTokensB2BWorkflow.ts:35-76
Timestamp: 2025-12-12T20:09:27.413Z
Learning: In TypeScript workflow files within the project (e.g., userTokens/addBillingDetailsToTokensB2BWorkflow.ts), use the field billing_customer_id as the customer identifier when interacting with the Kinde API. Do not reference customer_id. If any code previously uses customer_id, update it to billing_customer_id and verify downstream mappings, typings, and API requests align with the new field name.

Applied to files:

  • billing/trackOrgSeatUsageWorkflow.ts
🔇 Additional comments (4)
billing/trackOrgSeatUsageWorkflow.ts (4)

1-6: LGTM!

Imports are appropriate for the workflow functionality.


8-37: Well-documented workflow.

Clear explanation of prerequisites, required scopes, and usage context for B2B seat-based billing.


39-51: LGTM!

Workflow settings are correctly configured with appropriate trigger and failure policy for billing operations.


93-98: Good defensive check for billing configuration.

Properly guards against missing billing data with an informative log message.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

Comment thread billing/trackOrgSeatUsageWorkflow.ts
Comment thread billing/trackOrgSeatUsageWorkflow.ts Outdated
Comment thread billing/trackOrgSeatUsageWorkflow.ts Outdated
@dtoxvanilla1991
Copy link
Copy Markdown

I'll see if I can help get this merged.

@Arobce
Copy link
Copy Markdown
Contributor Author

Arobce commented Apr 8, 2026

Thanks @dtoxvanilla1991. Please let me know if you have any improvements/suggestions.

Copy link
Copy Markdown

@dtoxvanilla1991 dtoxvanilla1991 left a comment

Choose a reason for hiding this comment

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

Good work! Reviewed and left 8 comments. Please let me know if you are actively working on this and will resolve them all so we can get it moving. Otherwise I can create a separate branch and copy most of your logic in and get it reviewed internally. Would like to see this through soon! Thanks!

Comment on lines +132 to +140
const meterUsageResponse = await kindeAPI.post({
endpoint: `billing/meter_usage`,
params: {
customer_agreement_id: billingCustomerAgreementId,
billing_feature_code: billingFeatureCode,
meter_value: "1",
meter_type_code: "delta",
},
});
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.

const orgResponse = await kindeAPI.get({
endpoint: `organization?code=${orgCode}&expand=billing`,
});
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.

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

console.log(
`[INFO] Metered usage updated for organization ${orgCode} and user ${kindeUserId}`
);
} No newline at end of file
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.

Comment on lines +69 to +72
if (!orgCode || !event?.context?.user?.id) {
console.log('[DEBUG] Missing required parameters (orgCode or user ID). Exiting workflow safely.');
return;
}
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.

Comment on lines +88 to +90
const orgResponse = await kindeAPI.get({
endpoint: `organization?code=${orgCode}&expand=billing`,
});
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.

Comment on lines +107 to +109
const agreement = organization.billing.agreements.find(
(agr: any) => agr.plan_code === planCode
);
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.

@Arobce
Copy link
Copy Markdown
Contributor Author

Arobce commented Apr 25, 2026

Thanks @dtoxvanilla1991. I really appreciate the thoughtful and detailed feedback. I picked up a few new things from your comments as well! I’ll go through everything and get these commits resolved shortly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants