Skip to main content

Webhooks Module (Clerk)

This document describes the current Clerk webhooks module architecture in apps/api/src/modules/webhooks/clerk-webhooks.

Purpose

The Clerk webhooks module:

  • receives signed Clerk webhook requests
  • verifies signature at the HTTP adapter boundary
  • maps verified events into application-level handling
  • synchronizes user registration for user.created events
  • returns a normalized acknowledgment payload for verified events

Module structure

PathRole
composition.tsWires route adapter and use-case with injected user registration dependency
adapters/http/routes.tsFastify route, raw body setup, signature verification, response mapping
application/use-cases/handle-clerk-webhook.tsOrchestrates webhook handling with ports/domain entity
application/dtos/clerk-webhook.tsClerkWebhookCommand / ClerkWebhookResult
application/ports/user-registration.port.tsDependency contract used to register users
domain/entities/clerk-webhook-event.entity.tsDomain helpers for event-type decisions and payload extraction
clerk-fastify-webhooks.d.tsType declaration shim for @clerk/fastify/webhooks with current TS module resolution

Composition dependencies

createClerkWebhooksModule({ registerByIdentityProviderId }) requires:

  • registerByIdentityProviderId({ identityProviderUserId }): injected from users module use-case

It composes:

  1. handleClerkWebhook use-case (createHandleClerkWebhookUseCase)
  2. webhook routes (clerkWebhooksRoutes({ handleClerkWebhook }))

HTTP route

Current endpoint:

  • POST /webhooks/clerk/v1/user-created

Route adapter behavior:

  • registers fastify-raw-body plugin (global: false, runFirst: true)
  • enables config: { rawBody: true } on this route
  • checks that request.rawBody exists, otherwise returns:
    • 400 { "message": "Missing body" }
  • verifies signature with:
    • verifyWebhook(request, { signingSecret: process.env.CLERK_WEBHOOK_SIGNING_SECRET })
  • on verification failure returns:
    • 400 { "message": "Invalid webhook signature" }
  • on success calls handleClerkWebhook({ event }) and returns:
    • 200 { "received": true, "eventType": "<event.type>" }

Response schemas include:

  • 200 inline schema { received: boolean, eventType: string }
  • 400 BadRequestResponseSchema from contracts/system
  • 500 InternalServerErrorResponseSchema from contracts/system

Use-case and domain behavior

handle-clerk-webhook flow:

  1. Rehydrate domain entity from Clerk event.
  2. Check shouldRegisterUser():
    • true only for event.type === "user.created" and non-empty event.data.id.
  3. If true, call:
    • registerByIdentityProviderId({ identityProviderUserId: event.data.id })
  4. Return normalized result:
    • { received: true, eventType: event.type }

Important: verified non-user.created events are still acknowledged with 200, but no registration side-effect is triggered.

Signature and Clerk integration notes

  • Signature verification is performed at adapter boundary using @clerk/fastify/webhooks.
  • Raw payload is preserved to satisfy Standard Webhooks (Svix) verification requirements.
  • clerk-fastify-webhooks.d.ts exists because the package subpath typing is not resolved under current TS moduleResolution: "node" setup.

Runtime flow summary

  1. Clerk sends webhook to /webhooks/clerk/v1/user-created.
  2. Route ensures raw body is available.
  3. Route verifies signature using CLERK_WEBHOOK_SIGNING_SECRET.
  4. Verified event is passed to handleClerkWebhook.
  5. For user.created with data.id, users registration use-case is invoked.
  6. Route returns acknowledgment { received: true, eventType }.
  • Users Module - where registerByIdentityProviderId is implemented and composed.
  • Auth Module - request auth flow for normal user-facing endpoints.
  • Contracts and Types - boundaries between DTOs and shared contracts.