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 TypePurposeWhen to Use
Before hooksAuthorization, validationBlock operations that shouldn't proceed
After hooksSide effects, integrationsReact 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

RegistryHookWhen It Runs
afterOrganizationCreateRegistryafterCreateOrganizationOrganization created successfully
afterOrganizationUpdateRegistryafterUpdateOrganizationOrganization name/slug/logo updated
afterOrganizationDeleteRegistryafterDeleteOrganizationOrganization deleted
afterMemberAddRegistryafterAddMemberMember added directly (not via invitation)
afterMemberRemoveRegistryafterRemoveMemberMember removed or left organization
afterRoleUpdateRegistryafterUpdateMemberRoleMember's role changed
afterInvitationCreateRegistryafterCreateInvitationInvitation sent
afterInvitationAcceptRegistryafterAcceptInvitationInvitation accepted, member joined
afterInvitationCancelRegistryafterCancelInvitationInvitation canceled by admin
afterInvitationRejectRegistryafterRejectInvitationInvitation 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 service
import { 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.ts to 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.