Organization Lifecycle Hooks
Run custom code after organization operations like member changes, invitations, and deletions
Organization lifecycle hooks let you run custom code after operations complete successfully. Use them to update billing, send notifications, sync with external services, or perform cleanup tasks.
This guide is part of the Organizations documentation. Introduced in version 1.1.0.
Before vs After Hooks
| Hook Type | Purpose | When to Use |
|---|---|---|
| Before hooks | Authorization, validation | Block operations that shouldn't proceed |
| After hooks | Side effects, integrations | React to successful operations |
Before hooks are covered in Authorization Policies. This guide focuses on after hooks.
Architecture
┌─────────────────────────────────────────────────────────────────┐│ Better Auth ││ ┌─────────────────────────────────────────────────────────┐ ││ │ Organization Plugin │ ││ │ │ ││ │ Request → Before Hooks → Operation → After Hooks │ ││ │ │ │ │ │ ││ │ ▼ ▼ ▼ │ ││ │ Validate Execute Side Effects │ ││ │ (allow/deny) (DB write) (billing, etc.) │ ││ │ │ ││ └──────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────┘Available After Hooks
| Registry | Hook | When It Runs |
|---|---|---|
afterOrganizationCreateRegistry | afterCreateOrganization | Organization created successfully |
afterOrganizationUpdateRegistry | afterUpdateOrganization | Organization name/slug/logo updated |
afterOrganizationDeleteRegistry | afterDeleteOrganization | Organization deleted |
afterMemberAddRegistry | afterAddMember | Member added directly (not via invitation) |
afterMemberRemoveRegistry | afterRemoveMember | Member removed or left organization |
afterRoleUpdateRegistry | afterUpdateMemberRole | Member's role changed |
afterInvitationCreateRegistry | afterCreateInvitation | Invitation sent |
afterInvitationAcceptRegistry | afterAcceptInvitation | Invitation accepted, member joined |
afterInvitationCancelRegistry | afterCancelInvitation | Invitation canceled by admin |
afterInvitationRejectRegistry | afterRejectInvitation | Invitation rejected by invitee |
Context Types
Each after hook receives a context with information about the completed operation.
AfterOrganizationCreateContext
interface AfterOrganizationCreateContext { organizationId: string; // The new organization's ID userId: string; // User who created it timestamp: string; // ISO timestamp}AfterOrganizationUpdateContext
interface AfterOrganizationUpdateContext { organizationId: string; userId: string; timestamp: string;}AfterOrganizationDeleteContext
interface AfterOrganizationDeleteContext { organizationId: string; userId: string; timestamp: string;}AfterMemberAddContext
interface AfterMemberAddContext { organizationId: string; userId: string; // User who added the member memberId: string; // The new member's ID memberRole: string; // Role assigned timestamp: string;}AfterMemberRemoveContext
interface AfterMemberRemoveContext { organizationId: string; userId: string; // User who removed the member removedUserId: string; // The removed member's ID timestamp: string;}AfterRoleUpdateContext
interface AfterRoleUpdateContext { organizationId: string; userId: string; // User who changed the role targetUserId: string; // Member whose role changed previousRole: string; // Old role newRole: string; // New role timestamp: string;}AfterInvitationCreateContext
interface AfterInvitationCreateContext { organizationId: string; inviterId: string; // User who sent the invitation invitationId: string; inviteeEmail: string; inviteeRole: string; timestamp: string;}AfterInvitationAcceptContext
interface AfterInvitationAcceptContext { organizationId: string; userId: string; // User who accepted invitationId: string; memberId: string; // New member ID timestamp: string;}AfterInvitationCancelContext
interface AfterInvitationCancelContext { organizationId: string; userId: string; // User who canceled invitationId: string; timestamp: string;}AfterInvitationRejectContext
interface AfterInvitationRejectContext { userId: string; // User who rejected invitationId: string; organizationId: string; timestamp: string;}Registering After Hooks
Step 1: Define Your Policy
Create or edit packages/organization/policies/src/policies/custom.ts:
packages/organization/policies/src/policies/custom.ts
import { definePolicy, allow } from '@kit/policies';import type { AfterMemberRemoveContext } from '../types';import { getLogger } from '@kit/shared/logger';export const logMemberRemovalPolicy = definePolicy<AfterMemberRemoveContext>({ id: 'after-member-remove.audit-log', evaluate: async (context) => { const logger = await getLogger(); logger.info({ event: 'member_removed', organizationId: context.organizationId, removedUserId: context.removedUserId, removedBy: context.userId, timestamp: context.timestamp, }); // After hooks should always allow (they run after the operation) return allow(); },});Step 2: Register the Policy
Edit packages/organization/policies/src/registry.ts:
packages/organization/policies/src/registry.ts
import { logMemberRemovalPolicy } from './policies/custom';// Find the AFTER MEMBER REMOVE section and add:afterMemberRemoveRegistry.registerPolicy(logMemberRemovalPolicy);Common Use Cases
Update Billing on Member Changes
Update subscription seat count when members join or leave:
packages/organization/policies/src/policies/custom.ts
import { definePolicy, allow } from '@kit/policies';import type { AfterInvitationAcceptContext, AfterMemberRemoveContext,} from '../types';// Import your seat billing serviceimport { updateSubscriptionSeats } from '~/lib/billing/seat-billing.service';export const updateSeatsOnJoinPolicy = definePolicy<AfterInvitationAcceptContext>({ id: 'after-invitation-accept.update-seats', evaluate: async (context) => { try { await updateSubscriptionSeats(context.organizationId); } catch (error) { // Log but don't fail - the member already joined console.error('Failed to update seats:', error); } return allow(); },});export const updateSeatsOnLeavePolicy = definePolicy<AfterMemberRemoveContext>({ id: 'after-member-remove.update-seats', evaluate: async (context) => { try { await updateSubscriptionSeats(context.organizationId); } catch (error) { console.error('Failed to update seats:', error); } return allow(); },});Then register both:
packages/organization/policies/src/registry.ts
import { updateSeatsOnJoinPolicy, updateSeatsOnLeavePolicy,} from './policies/custom';afterInvitationAcceptRegistry.registerPolicy(updateSeatsOnJoinPolicy);afterMemberRemoveRegistry.registerPolicy(updateSeatsOnLeavePolicy);See Per-Seat Billing for a complete implementation.
Send Notifications
Notify team members when someone joins:
packages/organization/policies/src/policies/custom.ts
import { definePolicy, allow } from '@kit/policies';import type { AfterInvitationAcceptContext } from '../types';export const notifyOnMemberJoinPolicy = definePolicy<AfterInvitationAcceptContext>({ id: 'after-invitation-accept.notify', evaluate: async (context) => { // Queue a notification (don't await to avoid blocking) queueNotification({ type: 'member_joined', organizationId: context.organizationId, newMemberId: context.memberId, }).catch(console.error); return allow(); },});Sync with External Services
Update your CRM or analytics when organizations change:
packages/organization/policies/src/policies/custom.ts
import { definePolicy, allow } from '@kit/policies';import type { AfterOrganizationCreateContext } from '../types';export const syncCrmOnOrgCreatePolicy = definePolicy<AfterOrganizationCreateContext>({ id: 'after-org-create.sync-crm', evaluate: async (context) => { // Fire-and-forget to external service fetch('https://api.crm.com/organizations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ externalId: context.organizationId, createdBy: context.userId, createdAt: context.timestamp, }), }).catch(console.error); return allow(); },});Track Analytics Events
Record events for product analytics:
packages/organization/policies/src/policies/custom.ts
import { definePolicy, allow } from '@kit/policies';import type { AfterRoleUpdateContext } from '../types';import { posthog } from 'posthog-js';export const trackRoleChangePolicy = definePolicy<AfterRoleUpdateContext>({ id: 'after-role-update.analytics', evaluate: async (context) => { posthog.capture('role_updated', { organizationId: context.organizationId, targetUserId: context.targetUserId, previousRole: context.previousRole, newRole: context.newRole, updatedBy: context.userId, }); return allow(); },});Cleanup on Organization Delete
The kit includes built-in cleanup for organization storage. You can add additional cleanup:
packages/organization/policies/src/policies/custom.ts
import { definePolicy, allow } from '@kit/policies';import type { AfterOrganizationDeleteContext } from '../types';export const cleanupOnDeletePolicy = definePolicy<AfterOrganizationDeleteContext>({ id: 'after-org-delete.cleanup', evaluate: async (context) => { // Clean up organization-specific resources await Promise.allSettled([ deleteOrganizationFiles(context.organizationId), removeFromSearchIndex(context.organizationId), notifyExternalServices(context.organizationId, 'deleted'), ]); return allow(); },});Error Handling
After hooks run after the operation completes. Errors in after hooks should be logged but not thrown, as the operation has already succeeded:
export const safeAfterHookPolicy = definePolicy<AfterMemberRemoveContext>({ id: 'after-member-remove.safe-handler', evaluate: async (context) => { try { await riskyExternalCall(context); } catch (error) { // Log the error but don't throw const logger = await getLogger(); logger.error({ error, context }, 'After hook failed'); } // Always return allow for after hooks return allow(); },});Testing After Hooks
Test after hooks like any other policy:
import { describe, it, expect, vi } from 'vitest';import { updateSeatsOnJoinPolicy } from './custom';vi.mock('~/lib/billing/seat-billing.service', () => ({ updateSubscriptionSeats: vi.fn(),}));describe('updateSeatsOnJoinPolicy', () => { it('updates seats when member joins', async () => { const { updateSubscriptionSeats } = await import( '~/lib/billing/seat-billing.service' ); await updateSeatsOnJoinPolicy.evaluate({ organizationId: 'org-123', userId: 'user-456', invitationId: 'inv-789', memberId: 'member-abc', timestamp: new Date().toISOString(), }); expect(updateSubscriptionSeats).toHaveBeenCalledWith('org-123'); });});Common Pitfalls
- Throwing errors in after hooks: The operation already succeeded. Log errors instead of throwing to avoid confusing error states.
- Blocking on slow operations: Use fire-and-forget patterns or message queues for slow external calls.
- Forgetting to register policies: Policies must be registered in
registry.tsto be executed. - Returning deny from after hooks: After hooks should always return
allow(). Use before hooks for authorization. - Heavy synchronous work: After hooks run in the request path. Offload heavy work to background jobs.
Related
- Authorization Policies — Before hooks for blocking operations
- Per-Seat Billing — Example: update billing on member changes
- Billing Lifecycle Hooks — Hooks for subscription events