关于 convex-http-actions
面向 Convex 开发者的技能指南,介绍如何构建部署于 `.convex.site` 的 HTTP 端点,涵盖 Webhook、普通 HTTP API、文件传输与流式响应。
主要功能
- 01创建 HTTP 路由与处理请求
- 02验证 Webhook 签名并去重事件
- 03通过内部函数调用查询和变更
- 04支持 CORS、身份验证与流式响应
- 05上传、提供和下载文件
适用场景
- 01接收第三方服务发送的 Webhook
- 02为不使用 Convex 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 actions
定义由部署的 .convex.site 域名提供服务的公共 HTTP 端点。唯一的规则:httpAction 处理程序将请求的每个字节都视为不可信,先验证,再通过 ctx.runQuery 或 ctx.runMutation 调用 internal.* 函数。它绝不会直接读取或写入 ctx.db。
适用场景
- 第三方服务(Stripe、Clerk、Resend、GitHub)需要一个用于 POST 事件的 URL
- 不使用 Convex SDK 的客户端(移动应用、CLI、另一个后端)需要普通的 HTTP API
- 浏览器需要下载或流式传输字节,而不经过
useQuery - 脚本或表单需要通过 HTTP 上传字节,而不是使用上传 URL
httpAction 还是 mutation
| 调用方 | 使用方式 |
|---|---|
使用 convex/react 的自有 React 或 Next.js 应用 | query 和 mutation。跳过 HTTP actions。 |
| 发送 Webhook 的外部服务 | httpAction |
| 无法使用 Convex SDK 的客户端 | httpAction |
| 定时或内部任务 | internalMutation 或 internalAction |
HTTP actions 没有参数验证器,也不会自动进行身份验证。它们在默认的 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.site,而不是 .convex.cloud。path 必须完全匹配;对于动态路径段,请使用 pathPrefix: "/api/items/",并从 new URL(request.url).pathname 中读取剩余路径。
在处理程序中调用查询和 mutation
ctx.runQuery、ctx.runMutation、ctx.runAction、ctx.storage、ctx.scheduler 和 ctx.auth 都可在 httpAction 中使用。将它们指向 internal.* 函数,避免数据库逻辑也作为公共 Convex 函数公开。参数仍会经过目标函数的验证器,因此格式错误的参数会在该处触发清晰的错误。
// 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 });
},
});
对于耗时较长的工作(发送电子邮件、调用大模型),应立即响应 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 响应,有些服务提供商会重试;遇到网络波动时也可能重试,因此同一事件可能会收到多次。在 mutation 内部(这是一个事务)按事件 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;
},
});
Schema:webhookEvents: defineTable({ source: v.string(), eventId: v.string(), type: v.string(), payload: v.any() }).index("by_source_and_event_id", ["source", "eventId"])。
打开 references/webhooks.md,查看 Stripe(在 Node action 中使用 constructEvent)、Clerk 和 Resend(svix 标头)、带时间戳的重放保护,以及包含状态跟踪的更完整幂等性表。
CORS、路径参数、身份验证标头、流式传输、文件
浏览器会在跨源 POST 请求前发送 OPTIONS 预检请求,因此每个面向浏览器的路径都需要一个 OPTIONS 路由,并返回与实际路由相同的 Access-Control-* 标头。使用已配置的身份验证服务提供商颁发的 Bearer 令牌时,通过 await ctx.auth.getUserIdentity() 读取;查询中也使用相同的调用。通过返回 new Response(readableStream) 即可进行流式传输;使用 ctx.storage.get(storageId) 提供文件,或通过 request.blob() 接收文件,再调用 ctx.storage.store(blob) 保存。
构建 REST 风格的 API 时,请打开 references/rest-and-cors.md,了解路径参数和 ID 验证、CORS 预检请求配对、JSON 错误约定、Bearer 和 API 密钥身份验证、流式响应,以及文件上传和下载路由。
常见错误
| 错误 | 出错原因 | 正确做法 |
|---|---|---|
将路由器放在 convex/api.ts 或 convex/routes.ts 中 | 只有 convex/http.ts 会作为路由器加载 | 保留一个 http.ts,并使用 export default http |
调用 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 安排任务 |
| 对同一事件进行多次处理 | 服务提供商会在任何非 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.siteURL - JSON 响应设置
Content-Type: application/json