🔗

Clerk の Webhook でアプリケーションと同期を取る

に公開

第1回では、Next.jsアプリケーションにClerkを導入してフロントエンドの認証機能を実装しました。第2回では、HonoとClerkを組み合わせてバックエンドAPIの認証を実現し、安全なAPI通信を構築しました。

これまでの実装で、ユーザーの認証は動作するようになりましたが、実際のアプリケーション開発では「認証だけでは足りない」ケースが多数存在します。

実際のアプリケーション開発では、基本的なユーザー情報はClerkから取得できるものの、ユーザー固有の設定(テーマ、通知設定など)やアプリケーション固有のデータ(購入履歴など)、そしてStripeなどの外部サービスとの連携情報は、アプリケーション独自のデータベースで管理する必要があります。

そのため、新規ユーザー登録時にはこれらのデータを初期化し、ユーザー削除時には関連データをすべて削除するといった、Clerkのユーザーイベントに連動した処理が不可欠です。

本記事では、Clerk Webhookを使用してこれらのイベントを自動的にキャッチし、アプリケーションデータベースとClerkの完全な同期を実現する方法を解説します。WebhookとHonoを組み合わせることで、リアルタイムかつ確実なデータ同期システムを構築していきます。


本記事では、Webhook による Clerk とアプリケーションデータの同期を取り扱います。

  1. Clerk で爆速認証実装 - Next.js アプリに 10 分で認証機能を追加する
  2. Clerk × Hono で実現するシンプルで安全なバックエンド認証
  3. Clerk の Webhook でアプリケーションと同期を取る(今ここ)

実行環境

  • OS: Ubuntu 22.04.5 LTS(WSL2)
  • Node.js: v22.16.0
  • pnpm: 10.12.2
  • Next.js: 15.3.5
  • React: ^19.0.0
  • @clerk/nextjs: ^6.27.1
  • @clerk/backend: ^2.10.1
  • @hono/clerk-auth: ^3.0.3
  • hono: ^4.7.11
  • svix: ^1.76.1
  • dotenv: ^17.2.2
  • drizzle-orm: ^0.44.5

また、devDepenciesとして以下のパッケージを追加しています。

  • drizzle-kit: ^0.31.4

Webhookとは

Webhookは、特定のイベントが発生した際に、自動的に指定されたURLに HTTP POST リクエストを送信する仕組みです。外部サービスの挙動によってアプリケーションで処理をしたり、他の外部サービスと連携したりする際に使用します。また、定期的にデータを取得しに行く必要がなく、イベント発生時に通知を受け取ることができるため、リアルタイムの整合性を確保しやすいというメリットがあります。

多くのWebサービスがWebhookを提供しており、外部システムとの連携を可能にしています。例えば、GitHubでは、プッシュ時やプルリクエストの作成時にリクエストを送信するなどが可能です。

このようなWebhookを使用し、外部ツールと組み合わせることで様々なユースケースを解決できます。例えば、GitHubのプルリクエスト作成時に社内のチャットアプリへ通知を送信したり、Stripe決済完了時に在庫システムを自動更新するなどです。また、今回扱うようなClerkでユーザーを削除した際にアプリケーションの関連データも同時に削除するなど、SaaSと独自アプリケーションとの整合性を取るような仕組みもWebhookに支えられています。

Clerkの代表的なイベントとしては user.createduser.deleted があります。これらのイベントを例に、具体的にアプリケーションと連携する方法を解説していきます。

事前準備

アプリケーションのデプロイ

Webhookを送信するには、Webhookの送信先であるアプリケーションのURLが必要になります。現状はローカル開発環境での動作確認しかしていないため、Cloudflare Workersにアプリケーションをデプロイし、アクセスできるURLを取得します。

pnpm run deploy

デプロイが完了したら、Cloudflareダッシュボードから workers.dev URLを取得し、ClerkダッシュボードでWebhookを設定します。

workers.dev URL

Clerk ダッシュボードでWebhook送信先を設定する

Clerk ダッシュボードの設定ページで取得したworkers.dev URLにWebhookイベントが送信されるように設定します。
URLにはパスも登録する必要があります。今回はhttps://<app>.<user-name>.workers.dev/api/clerk/webhookとします。
今回受け取るWebhookイベントは、前述のとおりuser.createduser.deletedです。

clerk dashboard

「Create」ボタンをクリックし、その後表示される「Signing Secret」をコピー、メモしておいてください。

必要なライブラリのインストール

今回は、DBの管理・利用にDrizzle ORM、Drizzle Kit、dotenvを使用します。また、Webhookの検証にSvixを利用しています。

pnpm add svix drizzle-orm dotenv
pnpm add -D drizzle-kit

DBとテーブルの作成

今回は、D1を使用してユーザーテーブルを管理します。
まずは初期化のため、wranglerコマンドを使用してDBを作成します。

pnpm wrangler d1 create sample_db

ここで、? Would you like Wrangler to add it on your behalf?と聞かれるため、Yes, but let me choose the binding nameを選択します。
What binding name would you like to use?と聞かれるため、DBと入力し確定してください。自動的にこれらの接続情報がwrangler.jsoncファイルに書き込まれます。
この時、database_idの値をメモしておいてください。

その後、ルート直下にdrizzle.config.tsファイルを作成し、以下のように記述します。

drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'sqlite',
  driver: 'd1-http',
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
    token: process.env.CLOUDFLARE_D1_TOKEN!,
  },
});

.envにはCLOUDFLARE_ACCOUNT_IDCLOUDFLARE_DATABASE_IDCLOUDFLARE_D1_TOKENを設定します。

アカウントID、データベースID、APIトークンの取得方法

それぞれの値の取得方法は以下の通りです。

CLOUDFLARE_ACCOUNT_ID

以下のコマンドを実行すると、アカウント名とアカウントIDが表示されます。このアカウントIDを設定してください。

pnpm wrangler whoami
┌─────────────────────────────────────┬──────────────────────────────────┐
│ Account Name                        │ Account ID                       │
├─────────────────────────────────────┼──────────────────────────────────┤
│ xxxxxxxxxxxxxxxxxxxxxxxxx's Account │ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
└─────────────────────────────────────┴──────────────────────────────────┘

CLOUDFLARE_DATABASE_ID

データベースを作成した際に表示されたIDを入力してください。

CLOUDFLARE_D1_TOKEN

トークンはCloudflareダッシュボードから作成する必要があります。まず、ヘッダー右のアイコンをクリックし、「プロフィール」を選択します。画面が遷移するため、左部の「APIトークン」をクリック、「トークンを作成する」を選択してください。

api token

その後、「カスタムトークンを作成する」の「始める」をクリックします。

カスタムトークンの作成画面で、トークン名、権限、アカウントリソースを設定します。(必要に応じて他の項目も設定してください。)
トークン名にはわかりやすい名前を付けてください。権限にはD1の編集権限をつけ、アカウント リソースで自分のアカウントを選択します。

api token

設定後、画面に従って進んでいくとAPIトークンが表示されますので、コピーして入力してください。

その後、.envファイルにCloudflareのアカウントID、データベースID、APIトークンを書き込みます。

.env
CLOUDFLARE_ACCOUNT_ID=xxxxxxxxxxxxxxx...
CLOUDFLARE_DATABASE_ID=xxxxxxxxxxxxxxx...
CLOUDFLARE_D1_TOKEN=xxxxxxxxxxxxxxx...

次に、テーブルのスキーマを作成します。今回は連携できることを確認するのが目的のため、あまり属性は増やさずに、ユーザーIDと名前だけを持つようにします。

src/の下に、db/schema.tsを作成し、以下のようにスキーマを定義します。

src/db/schema.ts
import { sqliteTable, text } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
    id: text().primaryKey(),
    name: text(),
})

これでテーブルのスキーマが作成できたため、それに従いD1にテーブルを作成します。

pnpm drizzle-kit push

これでデータベースとテーブルの作成は完了です。Cloudflareのダッシュボードで D1 にテーブルが作成されていることを確認してください。

環境変数の設定

ClerkのSigning Secretを.dev.varsに設定します。

.dev.vars
CLERK_SIGNING_SECRET=whsec_xxxxxxx...

環境変数を設定したら、これらを型安全に使用するために以下のコマンドを実行します。

pnpm cf-typegen

このコマンドを実行することにより、dev.vars内で定義した環境変数の型定義ファイル(cloudflare-env.d.ts)がルートに作成され、補完やチェックができるようになります。

また、ローカルではなく、Cloudflare Workers上でも環境変数が利用できるように、環境変数をCloudflare上にプッシュします。

pnpm wrangler secret put <ENV_KEY_NAME>

? Enter a secret value:と聞かれるため、環境変数の値を設定してください。
ここで設定する必要があるのは以下の3つです。

  1. NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
  2. CLERK_SECRET_KEY
  3. CLERK_SIGNING_SECRET

Webhook の実装

APIルートの作成

APIルートを作成し、Webhookイベントを受け取れるようにします。
Webhookの送信者はアプリケーションのユーザーではないため、ユーザー認証を行っているミドルウェアの前にルートを配置する必要があります。
この特性上、Webhookのエンドポイントは保護されていないため、Webhook特有の認証ロジックを記述する必要があります。

src/app/api/[[...routes]]/route.ts
const app = new Hono()
    .basePath("/api")
    // 認証ミドルウェアより前に宣言
    .post("/clerk/webhook", async c => {
        // Webhook処理を記述
    })
    // 認証ミドルウェア
    .use((c, next) => {
        const clerkAuth = c.get("clerkAuth")
        const auth = clerkAuth()
        if (!auth || !auth.userId) throw new HTTPException(401, {
            message: "You are not logged in."
        })
        return next()
    })
    //その他のミドルウェア、エンドポイント

イベントの検証

このエンドポイントではまず、Webhookのイベントを検証します。前述した通り、Webhookイベントはユーザー起因のものではなく、公開されたエンドポイントのため、本当にClerkから送信されたイベントなのかを検証する必要があります。
イベントの検証にはsvixを使用します。bodyとheadersを受け取り、環境変数に設定したシークレットで署名検証を行うといった流れです。

src/app/api/[[...routes]]/route.ts
import { Webhook } from "svix"
import { WebhookEvent } from "@clerk/backend"

const app = new Hono()
    .basePath("/api")
    // 認証ミドルウェアより前に宣言
    .post("/clerk/webhook", async c => {
        // payloadとheadersを取得
        // req.validではなく、req.jsonを使用
        const payload = JSON.stringify(await c.req.json())
        const headers = c.req.header()

        // 環境変数からシークレットを取得
        const clerkSigningSecret = getCloudflareContext().env.CLERK_SIGNING_SECRET

        // svixを使用してイベントを検証
        // 検証後、戻り値をWebhookEvent型にアサーション
        const webhook = new Webhook(clerkSigningSecret)
        const event = webhook.verify(payload, headers) as WebhookEvent
    })
    //その他のミドルウェア、エンドポイント

検証後、戻り値をClerkのWebhookEvent型にアサーションすることで型安全にイベントを扱うことが可能です。

DBへの挿入、削除

これまでで、イベントを受け取り検証し、アサーションによりイベントを静的に判断することが可能になりました。
この後は、実際にデータベースに対しイベントに基づいて挿入、削除を行います。

src/app/api/[[...routes]]/route.ts
import { Webhook } from "svix"
import { WebhookEvent } from "@clerk/backend"
import { users } from "@/db/schema"

const app = new Hono()
    .basePath("/api")
    // 認証ミドルウェアより前に宣言
    .post("/clerk/webhook", async c => {
        // payloadとheadersを取得
        // req.validではなく、req.jsonを使用
        const payload = JSON.stringify(await c.req.json())
        const headers = c.req.header()

        // 環境変数からシークレットを取得
        const clerkSigningSecret = getCloudflareContext().env.CLERK_SIGNING_SECRET

        // svixを使用してイベントを検証
        // 検証後、戻り値をWebhookEvent型にアサーション
        const webhook = new Webhook(clerkSigningSecret)
        const event = webhook.verify(payload, headers) as WebhookEvent

        // バインディングからDBを取得し、ORMを初期化
        const db = drizzle(getCloudflareContext().env.DB)

        // イベントが user.created だった場合にIDと名前を挿入
        if (event.type === "user.created") {
            await db.insert(users).values({
                id: event.data.id,
                name: event.data.username
            })
        }

        // イベントが user.deleted だった場合にユーザーを削除
        if (event.type === "user.deleted" && event.data.id) {
            await db.delete(users).where(eq(users.id, event.data.id))
        }

        return c.text("webhook received!", 200)
    })
    //その他のミドルウェア、エンドポイント

デプロイ & 動作確認

変更をCloudflareに適用するためにデプロイします。

pnpm run deploy

その後、ローカルではなく、 Cloudflare Workers でデプロイしたアプリケーション(https://<app>.<user-name>.workers.dev/)にアクセスします。
アクセスしたらログイン(サインアップ)を完了させ、D1のusersテーブルにユーザーのIDと名前が登録されていることを確認します。

explore data
サイドバーから「D1 SQLデータベース」を選択、「Explore Data」を選択します

user found
サイドバーから「users」をダブルクリックし、テーブルを確認します

このように、登録したユーザーのIDと名前がD1に存在しています。

次に、ユーザーを削除します。上記の手順と同じように、ユーザーを削除してデータベースからもユーザーが削除されていることを確認します。

delete user
ユーザーを削除します

user deleted
更新ボタンをクリックし、テーブルを確認します

次はユーザーが削除されたイベントをもとに、D1からも削除されたことが確認できました。

まとめ

本記事では、ClerkのWebhookを使用したリアルタイムなユーザーデータ同期システムの構築方法を紹介しました。
Webhookを使用することで、複雑な技術を使わずにClerkとアプリケーションとでデータを同期させることができます。このほかにも様々なイベントがあるのでぜひ活用してみてください。

また、これで全3回にわたる Next.js × Hono × Clerk で素早く構築するウェブアプリケーションの解説記事は終了になります。
これらの記事がスピーディかつ安全なアプリケーションの構築の第一歩になれば幸いです。

株式会社SCC - テクノロジーセンター

Discussion