convex-http-actions について
`.convex.site` 上の HTTP エンドポイントを構築する Convex 開発者向けのスキルガイドです。Webhook、HTTP API、ファイル転送、ストリーミング応答を扱います。
主な機能
- 01HTTP ルートの定義とリクエスト処理
- 02Webhook 署名の検証とイベント重複排除
- 03内部関数経由でのクエリとミューテーション呼び出し
- 04CORS、認証、ストリーミングへの対応
- 05ファイルのアップロード、提供、ダウンロード
活用シーン
- 01サードパーティサービスから Webhook を受信
- 02Convex SDK を使わないクライアントへの API 提供
- 03ブラウザーやスクリプト経由でのファイル転送
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-http-actionsインストール後、Claude Code で/convex-http-actionsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamlassets/small-logo.svgreferences/rest-and-cors.mdwebhooks.md詳細
Convex HTTP アクション
デプロイメントの .convex.site ドメインで提供されるパブリック HTTP エンドポイントを定義します。ルールは 1 つです。httpAction ハンドラーはリクエストのすべてのバイトを信頼できないものとして扱い、検証してから、ctx.runQuery または ctx.runMutation で internal.* 関数を呼び出します。ctx.db を直接読み書きすることはありません。
どのような場合に使うか
- サードパーティ(Stripe、Clerk、Resend、GitHub)がイベントを POST する URL を必要としている
- Convex SDK を使わないクライアント(モバイルアプリ、CLI、別のバックエンド)が通常の HTTP API を必要としている
- ブラウザーが
useQueryを経由せずにバイトをダウンロードまたはストリーミングする必要がある - スクリプトやフォームがアップロード URL ではなく HTTP でバイトをアップロードする必要がある
httpAction または mutation
| 呼び出し元 | 使用するもの |
|---|---|
convex/react を使用する独自の React または Next.js アプリ | query と mutation。HTTP アクションは使いません。 |
| Webhook を送信する外部サービス | httpAction |
| Convex SDK を使えないクライアント | httpAction |
| スケジュール済みの処理または内部処理 | internalMutation または internalAction |
HTTP アクションには引数バリデーターも自動認証もありません。デフォルトの Convex ランタイムで実行されるため、Web API(fetch、crypto.subtle、Response、ReadableStream)は "use node" なしで動作します。リクエストとレスポンスの本文は 20MB に制限されています。
ルーターの基本構成
ルーターは convex/http.ts に配置し、デフォルトエクスポートにする必要があります。ほかのファイルにあるルーターは無視されます。
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/api/items",
method: "POST",
handler: httpAction(async (ctx, request) => {
let body: { name?: unknown };
try {
body = await request.json();
} catch {
return json({ error: "Invalid JSON body" }, 400);
}
if (typeof body.name !== "string") {
return json({ error: "name is required" }, 400);
}
const id = await ctx.runMutation(internal.items.create, { name: body.name });
return json({ id }, 201);
}),
});
export default http;
// JSON response helper shared by every route in this file
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
エンドポイントには https://<deployment>.convex.site/api/items でアクセスできます。.convex.cloud ではなく .convex.site であることに注意してください。path は完全一致です。動的セグメントには pathPrefix: "/api/items/" を使い、new URL(request.url).pathname から末尾のパスを読み取ってください。
ハンドラーからクエリとミューテーションを呼び出す
ctx.runQuery、ctx.runMutation、ctx.runAction、ctx.storage、ctx.scheduler、ctx.auth はすべて httpAction 内で使用できます。データベースのロジックが Convex のパブリック関数としても公開されないよう、internal.* 関数を指定してください。引数は引き続き呼び出し先のバリデーターを通るため、形式が不正な場合はそこで明確なエラーになります。
// convex/items.ts
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
export const create = internalMutation({
args: { name: v.string() },
returns: v.id("items"),
handler: async (ctx, args) => {
return await ctx.db.insert("items", { name: args.name });
},
});
メール送信や LLM の呼び出しなど、時間のかかる処理では、すぐに 200 を返し、ctx.scheduler.runAfter(0, internal.jobs.process, args) で処理を引き継ぎます。ハンドラーが停滞すると、プロバイダーはタイムアウトして再試行します。
署名検証付き Webhook
request.text() で生の本文を一度だけ読み取ります。先に解析するとバイト列が変わり、署名が無効になります。この汎用的な HMAC SHA-256 のパターンはほとんどのプロバイダーに対応しています。Stripe、Clerk、Resend 固有の情報は以下のリファレンスを参照してください。
// convex/http.ts (add to the router above)
http.route({
path: "/webhooks/provider",
method: "POST",
handler: httpAction(async (ctx, request) => {
const signature = request.headers.get("x-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const raw = await request.text();
const secret = process.env.PROVIDER_WEBHOOK_SECRET;
if (!secret) return new Response("Webhook secret not configured", { status: 500 });
if (!(await verifyHmac(raw, signature, secret))) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(raw) as { id: string; type: string; data: unknown };
// The mutation returns early if this event id was already processed.
await ctx.runMutation(internal.webhooks.record, {
source: "provider",
eventId: event.id,
type: event.type,
payload: event.data,
});
return new Response(null, { status: 200 });
}),
});
async function verifyHmac(payload: string, signature: string, secret: string): Promise<boolean> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(payload));
const expected = Array.from(new Uint8Array(mac))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return safeEqual(expected, signature);
}
// Constant time compare so response timing does not leak the signature
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
プロバイダーは 2xx 以外の応答やネットワークの一時的な障害があると再試行するため、同じイベントが複数回届くことがあります。トランザクションであるミューテーション内で、イベント ID による重複排除を行います。
// convex/webhooks.ts
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
export const record = internalMutation({
args: { source: v.string(), eventId: v.string(), type: v.string(), payload: v.any() },
returns: v.null(),
handler: async (ctx, args) => {
const seen = await ctx.db
.query("webhookEvents")
.withIndex("by_source_and_event_id", (q) =>
q.eq("source", args.source).eq("eventId", args.eventId),
)
.unique();
if (seen) return null;
await ctx.db.insert("webhookEvents", args);
// Apply the event's side effects here, in the same transaction.
return null;
},
});
スキーマ:webhookEvents: defineTable({ source: v.string(), eventId: v.string(), type: v.string(), payload: v.any() }).index("by_source_and_event_id", ["source", "eventId"])。
Stripe(Node アクション内の constructEvent)、Clerk と Resend(svix ヘッダー)、タイムスタンプを使ったリプレイ保護、ステータス追跡を含む、より詳しい冪等性テーブルについては、references/webhooks.md を参照してください。
CORS、パスパラメーター、認証ヘッダー、ストリーミング、ファイル
ブラウザーはクロスオリジンの POST の前に OPTIONS プリフライトを送信するため、ブラウザーからアクセスされる各パスには、実際のルートと同じ Access-Control-* ヘッダーを返す OPTIONS ルートが必要です。設定済みの認証プロバイダーのベアラートークンは、クエリで使うものと同じ await ctx.auth.getUserIdentity() を使って読み取ります。new Response(readableStream) を返すことでストリーミングでき、ctx.storage.get(storageId) でファイルを提供するか、request.blob() で受け取ってから ctx.storage.store(blob) で保存できます。
REST 形式の API を構築する場合は、パスパラメーターと ID の検証、CORS プリフライトのペア、JSON エラーの規約、ベアラー認証と API キー認証、ストリーミングレスポンス、ファイルのアップロードとダウンロードのルートについて説明している references/rest-and-cors.md を参照してください。
よくある間違い
| 間違い | 問題が発生する理由 | 代わりに行うこと |
|---|---|---|
convex/api.ts または convex/routes.ts にルーターを置く | ルーターとして読み込まれるのは convex/http.ts のみです | export default http を含む http.ts を1つだけ用意します |
https://<deployment>.convex.cloud/webhooks/... を呼び出す | HTTP アクションは .convex.site 上で動作します | プロバイダーのダッシュボードで .convex.site の URL を使用します |
署名を検証する前に await request.json() を呼び出す | 再シリアライズされた本文は、署名されたバイト列と一致しなくなります | request.text() を一度だけ呼び出し、署名を検証してから JSON.parse します |
| 「Stripe から送られてきた」からといって本文を信用する | 誰でも公開 URL に POST できます | すべてのリクエストで署名を検証します |
runMutation から api.* のターゲットを使用する | 同じロジックが未認証のまま二重に公開されます | internal.* 関数をターゲットにします |
ブラウザーから呼び出すエンドポイントに OPTIONS ルートがない | プリフライトに失敗し、実際のリクエストが送信されません | CORS ヘッダーを付けた OPTIONS ルートをパスごとに追加します |
| ハンドラー内で数分かかる処理を実行する | プロバイダーがタイムアウトして再試行し、処理が重複します | すぐに 200 を返し、ctx.scheduler.runAfter でスケジュールします |
| 同じイベントを2回処理する | プロバイダーは 2xx 以外の応答を受け取ると再試行します | ミューテーション内でイベント ID による重複排除を行います |
応答に Content-Type: application/json がない | クライアントが解析できないテキストを受け取ります | すべての JSON 応答で json() ヘルパーを使用します |
チェックリスト
- ルーターが
convex/http.tsにあり、デフォルトエクスポートされている - すべてのルートで
try/catch内に本文を解析し、不正な入力には 400 を返す - ハンドラーは
internal.*関数を呼び出し、ctx.dbを直接呼び出さない - Webhook ルートは
request.text()を一度だけ読み取り、解析する前に署名を検証する - Webhook のシークレットは
process.envから取得し、ハードコードせずデプロイ環境に設定する - ミューテーション内でプロバイダーのイベント ID による重複排除を行う
- 時間のかかる処理をスケジュールし、ハンドラーは速やかに 2xx を返す
- ブラウザーから呼び出すルートには、対応する
OPTIONSルートがある - プロバイダーのダッシュボードで
.convex.siteの URL を指定している - JSON 応答に
Content-Type: application/jsonを設定する