SKILL.md
唯讀
名稱
convex-http-actions
描述
外部 API 整合與 Webhook 處理,包括 HTTP 端點路由、請求/回應處理、驗證、CORS 設定,以及 Webhook 簽章驗證
版本
1.0.0
Convex HTTP Actions
在 Convex 應用程式中建立 HTTP 端點,用於 Webhook、外部 API 整合與自訂路由。
文件來源
實作前,請勿自行假設;請先取得最新文件:
- 主要文件:https://docs.convex.dev/functions/http-actions
- Actions 總覽:https://docs.convex.dev/functions/actions
- 驗證:https://docs.convex.dev/auth
- 更廣泛的內容:https://docs.convex.dev/llms.txt
指示
HTTP Actions 總覽
HTTP actions 可讓您在 Convex 中定義 HTTP 端點,以達成以下功能:
- 接收第三方服務的 Webhook
- 建立自訂 API 路由
- 處理檔案上傳
- 與外部服務整合
- 提供動態內容
基本 HTTP Router 設定
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
// 簡單的 GET 端點
http.route({
path: "/health",
method: "GET",
handler: httpAction(async (ctx, request) => {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
export default http;
請求處理
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
// 處理 JSON body
http.route({
path: "/api/data",
method: "POST",
handler: httpAction(async (ctx, request) => {
// 解析 JSON body
const body = await request.json();
// 存取 headers
const authHeader = request.headers.get("Authorization");
// 存取 URL 參數
const url = new URL(request.url);
const queryParam = url.searchParams.get("filter");
return new Response(
JSON.stringify({ received: body, filter: queryParam }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}),
});
// 處理表單資料
http.route({
path: "/api/form",
method: "POST",
handler: httpAction(async (ctx, request) => {
const formData = await request.formData();
const name = formData.get("name");
const email = formData.get("email");
return new Response(
JSON.stringify({ name, email }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}),
});
// 處理原始位元組
http.route({
path: "/api/upload",
method: "POST",
handler: httpAction(async (ctx, request) => {
const bytes = await request.bytes();
const contentType = request.headers.get("Content-Type") ?? "application/octet-stream";
// 儲存至 Convex storage
const blob = new Blob([bytes], { type: contentType });
const storageId = await ctx.storage.store(blob);
return new Response(
JSON.stringify({ storageId }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}),
});
export default http;
路徑參數
使用路徑前綴比對來處理動態路由:
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
// 使用 pathPrefix 比對 /api/users/*
http.route({
pathPrefix: "/api/users/",
method: "GET",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
// 從路徑中擷取使用者 ID:/api/users/123 -> "123"
const userId = url.pathname.replace("/api/users/", "");
return new Response(
JSON.stringify({ userId }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}),
});
export default http;
CORS 設定
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
// CORS headers 輔助函式
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
// 處理 preflight 請求
http.route({
path: "/api/data",
method: "OPTIONS",
handler: httpAction(async () => {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
}),
});
// 實際端點並加上 CORS
http.route({
path: "/api/data",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.json();
return new Response(
JSON.stringify({ success: true, data: body }),
{
status: 200,
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
}
);
}),
});
export default http;
Webhook 處理
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
// Stripe webhook
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, request) => {
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("缺少簽章", { status: 400 });
}
const body = await request.text();
// 驗證 webhook 簽章(在 action 中使用 Node.js)
try {
await ctx.runAction(internal.stripe.verifyAndProcessWebhook, {
body,
signature,
});
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Webhook 錯誤:", error);
return new Response("Webhook 錯誤", { status: 400 });
}
}),
});
// GitHub webhook
http.route({
path: "/webhooks/github",
method: "POST",
handler: httpAction(async (ctx, request) => {
const event = request.headers.get("X-GitHub-Event");
const signature = request.headers.get("X-Hub-Signature-256");
if (!signature) {
return new Response("缺少簽章", { status: 400 });
}
const body = await request.text();
await ctx.runAction(internal.github.processWebhook, {
event: event ?? "unknown",
body,
signature,
});
return new Response("OK", { status: 200 });
}),
});
export default http;
Webhook 簽章驗證
// convex/stripe.ts
"use node";
import { internalAction, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const verifyAndProcessWebhook = internalAction({
args: {
body: v.string(),
signature: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// 驗證簽章
const event = stripe.webhooks.constructEvent(
args.body,
args.signature,
webhookSecret
);
// 根據事件類型處理
switch (event.type) {
case "checkout.session.completed":
await ctx.runMutation(internal.payments.handleCheckoutComplete, {
sessionId: event.data.object.id,
customerId: event.data.object.customer as string,
});
break;
case "customer.subscription.updated":
await ctx.runMutation(internal.subscriptions.handleUpdate, {
subscriptionId: event.data.object.id,
status: event.data.object.status,
});
break;
}
return null;
},
});
HTTP Actions 中的驗證
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
// API 金鑰驗證
http.route({
path: "/api/protected",
method: "GET",
handler: httpAction(async (ctx, request) => {
const apiKey = request.headers.get("X-API-Key");
if (!apiKey) {
return new Response(
JSON.stringify({ error: "缺少 API 金鑰" }),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
// 驗證 API 金鑰
const isValid = await ctx.runQuery(internal.auth.validateApiKey, {
apiKey,
});
if (!isValid) {
return new Response(
JSON.stringify({ error: "無效的 API 金鑰" }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}
// 處理已驗證的請求
const data = await ctx.runQuery(internal.data.getProtectedData, {});
return new Response(
JSON.stringify(data),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}),
});
// Bearer token 驗證
http.route({
path: "/api/user",
method: "GET",
handler: httpAction(async (ctx, request) => {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response(
JSON.stringify({ error: "缺少或無效的 Authorization header" }),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
const token = authHeader.slice(7);
// 驗證 token 並取得使用者
const user = await ctx.runQuery(internal.auth.validateToken, { token });
if (!user) {
return new Response(
JSON.stringify({ error: "無效的 token" }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}
return new Response(
JSON.stringify(user),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}),
});
export default http;
呼叫 Mutations 與 Queries
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api, internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/api/items",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.json();
// 呼叫 mutation
const itemId = await ctx.runMutation(internal.items.create, {
name: body.name,
description: body.description,
});
// 查詢建立的項目
const item = await ctx.runQuery(internal.items.get, { id: itemId });
return new Response(
JSON.stringify(item),
{ status: 201, headers: { "Content-Type": "application/json" } }
);
}),
});
http.route({
path: "/api/items",
method: "GET",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get("limit") ?? "10");
const items = await ctx.runQuery(internal.items.list, { limit });
return new Response(
JSON.stringify(items),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}),
});
export default http;
錯誤處理
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
// JSON 回應輔助函式
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
// 錯誤回應輔助函式
function errorResponse(message: string, status: number) {
return jsonResponse({ error: message }, status);
}
http.route({
path: "/api/process",
method: "POST",
handler: httpAction(async (ctx, request) => {
try {
// 驗證 Content-Type
const contentType = request.headers.get("Content-Type");
if (!contentType?.includes("application/json")) {
return errorResponse("Content-Type 必須為 application/json", 415);
}
// 解析 body
let body;
try {
body = await request.json();
} catch {
return errorResponse("無效的 JSON body", 400);
}
// 驗證必填欄位
if (!body.data) {
return errorResponse("缺少必填欄位:data", 400);
}
// 處理請求
const result = await ctx.runMutation(internal.process.handle, {
data: body.data,
});
return jsonResponse({ success: true, result }, 200);
} catch (error) {
console.error("處理錯誤:", error);
return errorResponse("內部伺服器錯誤", 500);
}
}),
});
export default http;
檔案下載
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { Id } from "./_generated/dataModel";
const http = httpRouter();
http.route({
pathPrefix: "/files/",
method: "GET",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const fileId = url.pathname.replace("/files/", "") as Id<"_storage">;
// 從 storage 取得檔案 URL
const fileUrl = await ctx.storage.getUrl(fileId);
if (!fileUrl) {
return new Response("找不到檔案", { status: 404 });
}
// 重新導向至檔案 URL
return Response.redirect(fileUrl, 302);
}),
});
export default http;
範例
完整的 Webhook 整合
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
// Clerk webhook 用於使用者同步
http.route({
path: "/webhooks/clerk",
method: "POST",
handler: httpAction(async (ctx, request) => {
const svixId = request.headers.get("svix-id");
const svixTimestamp = request.headers.get("svix-timestamp");
const svixSignature = request.headers.get("svix-signature");
if (!svixId || !svixTimestamp || !svixSignature) {
return new Response("缺少 Svix headers", { status: 400 });
}
const body = await request.text();
try {
await ctx.runAction(internal.clerk.verifyAndProcess, {
body,
svixId,
svixTimestamp,
svixSignature,
});
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Clerk webhook 錯誤:", error);
return new Response("Webhook 驗證失敗", { status: 400 });
}
}),
});
export default http;
// convex/clerk.ts
"use node";
import { internalAction, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import { Webhook } from "svix";
export const verifyAndProcess = internalAction({
args: {
body: v.string(),
svixId: v.string(),
svixTimestamp: v.string(),
svixSignature: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;
const wh = new Webhook(webhookSecret);
const event = wh.verify(args.body, {
"svix-id": args.svixId,
"svix-timestamp": args.svixTimestamp,
"svix-signature": args.svixSignature,
}) as { type: string; data: Record<string, unknown> };
switch (event.type) {
case "user.created":
await ctx.runMutation(internal.users.create, {
clerkId: event.data.id as string,
email: (event.data.email_addresses as Array<{ email_address: string }>)[0]?.email_address,
name: `${event.data.first_name} ${event.data.last_name}`,
});
break;
case "user.updated":
await ctx.runMutation(internal.users.update, {
clerkId: event.data.id as string,
email: (event.data.email_addresses as Array<{ email_address: string }>)[0]?.email_address,
name: `${event.data.first_name} ${event.data.last_name}`,
});
break;
case "user.deleted":
await ctx.runMutation(internal.users.remove, {
clerkId: event.data.id as string,
});
break;
}
return null;
},
});
HTTP API 的 Schema
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
apiKeys: defineTable({
key: v.string(),
userId: v.id("users"),
name: v.string(),
createdAt: v.number(),
lastUsedAt: v.optional(v.number()),
revokedAt: v.optional(v.number()),
})
.index("by_key", ["key"])
.index("by_user", ["userId"]),
webhookEvents: defineTable({
source: v.string(),
eventType: v.string(),
payload: v.any(),
processedAt: v.number(),
status: v.union(
v.literal("success"),
v.literal("failed")
),
error: v.optional(v.string()),
})
.index("by_source", ["source"])
.index("by_status", ["status"]),
users: defineTable({
clerkId: v.string(),
email: v.string(),
name: v.string(),
}).index("by_clerk_id", ["clerkId"]),
});
最佳實務
- 除非明確指示,否則絕不執行
npx convex deploy - 除非明確指示,否則絕不執行任何 git 指令
- 務必驗證並清理傳入的請求資料
- 使用內部函式進行資料庫操作
- 使用適當的狀態碼實作錯誤處理
- 為瀏覽器可存取的端點加入 CORS headers
- 處理前務必驗證 Webhook 簽章
- 記錄 Webhook 事件以利除錯
- 使用環境變數儲存機密
- 妥善處理逾時
常見陷阱
- 缺少 CORS preflight 處理 - 瀏覽器會先傳送 OPTIONS 請求
- 未驗證 Webhook 簽章 - 安全性漏洞
- 暴露內部函式 - 應從 HTTP actions 使用內部函式
- 忘記 Content-Type headers - 用戶端可能無法正確解析回應
- 未處理請求 body 錯誤 - 無效的 JSON 會拋出例外
- 阻塞於長時間操作 - 使用排程函式處理繁重工作
參考資料
- Convex 文件:https://docs.convex.dev/
- Convex LLMs.txt:https://docs.convex.dev/llms.txt
- HTTP Actions:https://docs.convex.dev/functions/http-actions
- Actions:https://docs.convex.dev/functions/actions
- 驗證:https://docs.convex.dev/auth






