# Clerk Identity Integration

Synchronize Clerk users and organizations, then resolve sessions to MonetizeKit customers.

<!-- docs-source -->
Source: https://learning.monetizekit.app/docs/guides/clerk-identity
<!-- /docs-source -->

Clerk answers *who is this?*; MonetizeKit answers *what can they do?*. The seam between the two has two halves. The **packaged connection** (Integrations → Clerk) keeps your customer directory in sync: Clerk users and organizations become MonetizeKit customers, automatically, with webhook-driven lifecycle updates and an initial import for existing accounts. The **request-time half** — turning a verified Clerk session into a customer id for entitlement checks — runs in your app through the SDK's `IdentityResolver` extension point.

> [!NOTE] SDK half requires @monetizekit/node >= 0.2.0
>
> The packaged connection needs no SDK at all. For request-time resolution, `identityResolver` and `mk.resolveCustomerId(externalId, context)` ship in SDK v0.2.0.

## Step 1 — Connect your Clerk instance

In **Integrations → Clerk → Connect**, paste your Clerk secret key (`sk_test_…` or `sk_live_…`). MonetizeKit validates the key against the Clerk Backend API, inspects the instance (organizations enabled? how many users and organizations?), and *proposes* an account model. Nothing is applied silently — you confirm or override the proposal before anything connects. The key is envelope-encrypted at rest and never re-displayed.

| Account model | Mapping | Fits when |
| --- | --- | --- |
| `user (B2C)` | Each Clerk user → one MonetizeKit customer | Individual subscriptions; every user has their own plan, usage, and credits. |
| `organization (B2B)` | Each Clerk organization → one MonetizeKit customer | Team plans: entitlements, limits, and credit wallets are shared org-wide; members are seats. |
| `both` | Users and organizations each become customers | Products where personal and team workspaces coexist; your app decides per-session which subject to bill. |

The available models are exactly user, organization, both. If you later need per-member metering inside an org customer, model members as MonetizeKit *entities* under the customer rather than separate customers.

> [!NOTE] One Clerk instance, one MonetizeKit account
>
> A Clerk instance already connected to a different MonetizeKit account is refused at connect time. One instance can back multiple environments *within* one account (test instance → test environment, production → live); it can never span accounts.

## Step 2 — Register the webhook in Clerk

After connecting, the dialog shows your workspace's receiver URL: `{your MonetizeKit base URL}/api/webhooks/clerk-sync/{connectionId}`. In the Clerk Dashboard (**Configure → Webhooks → Add endpoint**), create an endpoint with that URL and subscribe it to exactly these event types:

| Event | What the sync does |
| --- | --- |
| `user.created / organization.created` | Creates a customer with the Clerk id stamped in its attributes (clerkUserId / clerkOrganizationId). |
| `user.updated / organization.updated` | Updates the customer's name and email; existing attributes are merged, never replaced. |
| `user.deleted / organization.deleted` | Archives the customer — it stops being billable, but usage and billing history are preserved. |

(6 event types total: user.created, user.updated, user.deleted, organization.created, organization.updated, organization.deleted.) Then copy the endpoint's signing secret (`whsec_…`) back into the MonetizeKit configure dialog. Deliveries are verified against that secret; unverifiable requests are rejected.

> [!TIP] Retries and out-of-order deliveries are handled
>
> Each delivery is applied at most once (deduplicated by Svix message id), and an event older than the state already applied for that user or organization is skipped — a replayed or late webhook never overwrites newer data.

## Step 3 — Import existing accounts

The webhook only covers accounts created *after* it exists. For everyone who signed up before, run **Import directory** from the configure dialog. The import pages through your Clerk instance (100 records per page), creates or updates the matching customers, and persists its progress — a failed run resumes where it stopped, and re-running it is idempotent (already-synced accounts converge to the same state instead of duplicating).

> [!WARNING] Email conflicts are surfaced, not guessed
>
> If an existing customer already has a Clerk user's email but is not linked to that Clerk id, the sync skips it and records a conflict rather than silently merging two identities. Resolve those from the dashboard.

## Unsynced accounts — an explicit choice

At connect time you record what your app does when it sees an authenticated Clerk account that has no synced customer yet (webhook delay, import not run). The connection stores this policy so the team's intent is explicit; your app's request-time code (next section) is where it takes effect — when `resolveCustomerId` returns `null`:

- `create_with_default_plan` (recommended) — treat first sight as signup: create the customer inline on your default plan so the user is never blocked. The sync's idempotent apply converges with it once the webhook lands.
- `deny_until_synced` — respond with “no billing account” until the sync catches up; correct for products where a customer must exist before any usage.

## Request-time resolution with the SDK

The sync stamps each customer with its Clerk id (in the customer's attributes, visible in the dashboard), but the customer API has no external-id lookup — you cannot ask “which customer belongs to Clerk user X?” per request. Store the mapping where your app already looks on every request: Clerk's `privateMetadata`.

```typescript
import { clerkClient } from "@clerk/nextjs/server";

const clerk = await clerkClient();
await clerk.users.updateUserMetadata(clerkUserId, {
  privateMetadata: { monetizekitCustomerId: customerId },
});
```

Then implement `IdentityResolver` once, hand it to the SDK constructor, and call `mk.resolveCustomerId(externalId, context)` wherever you have a Clerk user id:

```typescript
import { clerkClient } from "@clerk/nextjs/server";
import { MonetizeKit, type IdentityResolver } from "@monetizekit/node";

const apiKey = process.env.MONETIZEKIT_API_KEY;
if (!apiKey) throw new Error("MONETIZEKIT_API_KEY is required");

const clerkResolver: IdentityResolver = {
  async resolveCustomerId(clerkUserId) {
    const clerk = await clerkClient();
    const user = await clerk.users.getUser(clerkUserId);
    return (user.privateMetadata.monetizekitCustomerId as string | undefined) ?? null;
  },
};

export const mk = new MonetizeKit({ apiKey, identityResolver: clerkResolver });
```

```typescript
import { auth } from "@clerk/nextjs/server";
import { mk } from "@/lib/monetizekit";

export async function POST() {
  const { userId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const customerId = await mk.resolveCustomerId(userId);
  if (!customerId) return new Response("No billing account", { status: 403 });

  const decision = await mk.entitlements.check(customerId, "report_generation");
  if (!decision.allowed) {
    return Response.json(
      { error: decision.reasonCode, upgradeTo: decision.grantedByPlans },
      { status: 402 },
    );
  }
  return Response.json(await generateReport(customerId));
}
```

> [!WARNING] privateMetadata, not publicMetadata
>
> `publicMetadata` is readable by the browser. A customer id isn't a secret credential, but exposing it invites clients to send it to your API directly — and any endpoint that trusts a client-supplied customer id lets one user check (or spend) as another. Keep the id server-side and always derive it from the verified session.

## Optional — zero-lookup resolution via session claims

The resolver above costs one Clerk Backend API call per resolution. For hot paths, project the metadata into the session token so the customer id arrives with the request:

```typescript
import { auth } from "@clerk/nextjs/server";

export async function readBillingIdentityFromSession() {
  const { userId, sessionClaims } = await auth();
  const customerId = sessionClaims?.monetizekitCustomerId as string | undefined;
  return { userId, customerId };
}
```

## FAQ

### Do I still need my own Clerk webhook handler for provisioning?

No — customer creation, updates, and archival are handled by the packaged connection. Your app only needs the request-time half: reading the customer id from `privateMetadata` (or a session claim) and checking entitlements. If you previously provisioned customers from your own `user.created` handler, you can retire it; the sync's idempotent apply converges with customers it created.

### Should I key customers by email instead?

No. Emails change, and a Clerk user can carry several. The Clerk user id is the stable identifier — it is what the sync stamps on the customer — and the customer's email is display/billing contact data kept fresh by user.updated events, not a join key.

### What happens when a Clerk user is deleted?

The sync archives the customer: it stops being billable and disappears from active lists, but usage and billing history are preserved. If the deletion is a GDPR erasure request, additionally call the customer erasure endpoint (`POST /customers/{id}/erasure`) to anonymize identifiers while retaining financial records.

### Organizations have no email address — what does the customer get?

A deterministic placeholder (`{org id}@clerk-orgs.invalid`), since customers require a unique email per workspace. Treat it as sync-managed: the next `organization.updated` event re-applies it, so keep the real billing contact in the customer's `attributes` (which the sync merges, never replaces) rather than editing the email field.

## Related guides

### Node SDK Integration

The IdentityResolver extension point the request-time half builds on.

[Open guide](/docs/guides/sdk-node)

### Entitlement Evaluation Patterns

What to do with the decision once you have the customer id.

[Open guide](/docs/guides/entitlement-patterns)

### Webhook Verification and Retries

The same idempotent-consumer discipline, applied to MonetizeKit's own webhooks.

[Open guide](/docs/guides/webhooks)