# Organization Lifecycle Hooks

> Run custom code after organization operations like member changes, invitations, and deletions

*Canonical: https://makerkit.dev/docs/nextjs-prisma/organizations/lifecycle-hooks*

---

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](./overview) 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](./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

```typescript
interface AfterOrganizationCreateContext {
  organizationId: string;  // The new organization's ID
  userId: string;          // User who created it
  timestamp: string;       // ISO timestamp
}
```

### AfterOrganizationUpdateContext

```typescript
interface AfterOrganizationUpdateContext {
  organizationId: string;
  userId: string;
  timestamp: string;
}
```

### AfterOrganizationDeleteContext

```typescript
interface AfterOrganizationDeleteContext {
  organizationId: string;
  userId: string;
  timestamp: string;
}
```

### AfterMemberAddContext

```typescript
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

```typescript
interface AfterMemberRemoveContext {
  organizationId: string;
  userId: string;          // User who removed the member
  removedUserId: string;   // The removed member's ID
  timestamp: string;
}
```

### AfterRoleUpdateContext

```typescript
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

```typescript
interface AfterInvitationCreateContext {
  organizationId: string;
  inviterId: string;       // User who sent the invitation
  invitationId: string;
  inviteeEmail: string;
  inviteeRole: string;
  timestamp: string;
}
```

### AfterInvitationAcceptContext

```typescript
interface AfterInvitationAcceptContext {
  organizationId: string;
  userId: string;          // User who accepted
  invitationId: string;
  memberId: string;        // New member ID
  timestamp: string;
}
```

### AfterInvitationCancelContext

```typescript
interface AfterInvitationCancelContext {
  organizationId: string;
  userId: string;          // User who canceled
  invitationId: string;
  timestamp: string;
}
```

### AfterInvitationRejectContext

```typescript
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`:

```typescript {% title="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`:

```typescript {% title="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:

```typescript {% title="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:

```typescript {% title="packages/organization/policies/src/registry.ts" %}
import {
  updateSeatsOnJoinPolicy,
  updateSeatsOnLeavePolicy,
} from './policies/custom';

afterInvitationAcceptRegistry.registerPolicy(updateSeatsOnJoinPolicy);
afterMemberRemoveRegistry.registerPolicy(updateSeatsOnLeavePolicy);
```

See [Per-Seat Billing](/help/billing/per-seat-billing) for a complete implementation.

### Send Notifications

Notify team members when someone joins:

```typescript {% title="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:

```typescript {% title="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:

```typescript {% title="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:

```typescript {% title="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:

```typescript
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:

```typescript
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.

## Related

- [Authorization Policies](./authorization-policies) — Before hooks for blocking operations
- [Per-Seat Billing](/help/billing/per-seat-billing) — Example: update billing on member changes
- [Billing Lifecycle Hooks](/help/billing/lifecycle-hooks) — Hooks for subscription events
