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.
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.
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.
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).
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.
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:
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 });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));
}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:
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 guideEntitlement Evaluation Patterns
What to do with the decision once you have the customer id.
Open guideWebhook Verification and Retries
The same idempotent-consumer discipline, applied to MonetizeKit's own webhooks.
Open guide