convex-http-actions
External API integration and webhook handling including HTTP endpoint routing, request/response handling, authentication, CORS configuration, and webhook signature validation
- License 许可
- AGPL-3.0
- 所属仓库
- baisharg/ASTN
- 最近更新
- 2026-11-13
- Stars
- ★ 2
- Forks
- 2
01 前言 Front Matter
- name
- convex-http-actions
- tags
- [ "convex", "http", "actions", "webhooks", "api", "endpoints" ]
- author
- Convex
- version
- 1.0.0
- description
- External API integration and webhook handling including HTTP endpoint routing, request/response handling, authentication, CORS configuration, and webhook signature validation
- displayName
- Convex HTTP Actions
02 资源文件 5
- 其他kendex.settings.toml.example320 B
- 参考文档README.md1.2 KB
- 脚本scripts/deep-research40.9 KB
- 参考文档templates/findings.md551 B
- 脚本tests/deep-research.test.mjs44.9 KB
03 SKILL.md 正文
Convex HTTP Actions
Build HTTP endpoints for webhooks, external API integrations, and custom routes in Convex applications.
Documentation Sources
Before implementing, do not assume; fetch the latest documentation:
- Primary: https://docs.convex.dev/functions/http-actions
- Actions Overview: https://docs.convex.dev/functions/actions
- Authentication: https://docs.convex.dev/auth
- For broader context: https://docs.convex.dev/llms.txt
Instructions
HTTP Actions Overview
HTTP actions allow you to define HTTP endpoints in Convex that can:
- Receive webhooks from third-party services
- Create custom API routes
- Handle file uploads
- Integrate with external services
- Serve dynamic content
Basic HTTP Router Setup
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
const http = httpRouter()
// Simple GET endpoint
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
Request Handling
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
const http = httpRouter()
// Handle JSON body
http.route({
path: '/api/data',
method: 'POST',
handler: httpAction(async (ctx, request) => {
// Parse JSON body
const body = await request.json()
// Access headers
const authHeader = request.headers.get('Authorization')
// Access URL parameters
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' },
},
)
}),
})
// Handle form data
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' },
})
}),
})
// Handle raw bytes
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'
// Store in 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
Path Parameters
Use path prefix matching for dynamic routes:
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
const http = httpRouter()
// Match /api/users/* with pathPrefix
http.route({
pathPrefix: '/api/users/',
method: 'GET',
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url)
// Extract user ID from path: /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 Configuration
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
const http = httpRouter()
// CORS headers helper
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',
}
// Handle preflight requests
http.route({
path: '/api/data',
method: 'OPTIONS',
handler: httpAction(async () => {
return new Response(null, {
status: 204,
headers: corsHeaders,
})
}),
})
// Actual endpoint with 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 Handling
// 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('Missing signature', { status: 400 })
}
const body = await request.text()
// Verify webhook signature (in action with Node.js)
try {
await ctx.runAction(internal.stripe.verifyAndProcessWebhook, {
body,
signature,
})
return new Response('OK', { status: 200 })
} catch (error) {
console.error('Webhook error:', error)
return new Response('Webhook error', { 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('Missing signature', { 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 Signature Verification
// 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!
// Verify signature
const event = stripe.webhooks.constructEvent(
args.body,
args.signature,
webhookSecret,
)
// Process based on event type
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
},
})
Authentication in HTTP Actions
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
import { internal } from './_generated/api'
const http = httpRouter()
// API key authentication
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: 'Missing API key' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
// Validate API key
const isValid = await ctx.runQuery(internal.auth.validateApiKey, {
apiKey,
})
if (!isValid) {
return new Response(JSON.stringify({ error: 'Invalid API key' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
}
// Process authenticated request
const data = await ctx.runQuery(internal.data.getProtectedData, {})
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
})
// Bearer token authentication
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: 'Missing or invalid Authorization header' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } },
)
}
const token = authHeader.slice(7)
// Validate token and get user
const user = await ctx.runQuery(internal.auth.validateToken, { token })
if (!user) {
return new Response(JSON.stringify({ error: 'Invalid token' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(JSON.stringify(user), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
})
export default http
Calling Mutations and 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()
// Call a mutation
const itemId = await ctx.runMutation(internal.items.create, {
name: body.name,
description: body.description,
})
// Query the created item
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
Error Handling
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
const http = httpRouter()
// Helper for JSON responses
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
// Helper for error responses
function errorResponse(message: string, status: number) {
return jsonResponse({ error: message }, status)
}
http.route({
path: '/api/process',
method: 'POST',
handler: httpAction(async (ctx, request) => {
try {
// Validate content type
const contentType = request.headers.get('Content-Type')
if (!contentType?.includes('application/json')) {
return errorResponse('Content-Type must be application/json', 415)
}
// Parse body
let body
try {
body = await request.json()
} catch {
return errorResponse('Invalid JSON body', 400)
}
// Validate required fields
if (!body.data) {
return errorResponse('Missing required field: data', 400)
}
// Process request
const result = await ctx.runMutation(internal.process.handle, {
data: body.data,
})
return jsonResponse({ success: true, result }, 200)
} catch (error) {
console.error('Processing error:', error)
return errorResponse('Internal server error', 500)
}
}),
})
export default http
File Downloads
// 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'>
// Get file URL from storage
const fileUrl = await ctx.storage.getUrl(fileId)
if (!fileUrl) {
return new Response('File not found', { status: 404 })
}
// Redirect to the file URL
return Response.redirect(fileUrl, 302)
}),
})
export default http
Examples
Complete Webhook Integration
// convex/http.ts
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
import { internal } from './_generated/api'
const http = httpRouter()
// Clerk webhook for user sync
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('Missing 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:', error)
return new Response('Webhook verification failed', { 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
},
})
Schema for HTTP API
// 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']),
})
Best Practices
- Never run
npx convex deployunless explicitly instructed - Never run any git commands unless explicitly instructed
- Always validate and sanitize incoming request data
- Use internal functions for database operations
- Implement proper error handling with appropriate status codes
- Add CORS headers for browser-accessible endpoints
- Verify webhook signatures before processing
- Log webhook events for debugging
- Use environment variables for secrets
- Handle timeouts gracefully
Common Pitfalls
- Missing CORS preflight handler - Browsers send OPTIONS requests first
- Not validating webhook signatures - Security vulnerability
- Exposing internal functions - Use internal functions from HTTP actions
- Forgetting Content-Type headers - Clients may not parse responses correctly
- Not handling request body errors - Invalid JSON will throw
- Blocking on long operations - Use scheduled functions for heavy processing
References
- Convex Documentation: 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
- Authentication: https://docs.convex.dev/auth
04 同仓库更多 Skills
schema-builder
Design and generate Convex database schemas with proper validation, indexes, and relationships. Use when creating schema.ts or modifying table definitions.
integration-tanstack-start
PostHog integration for TanStack Start full-stack applications
migration-helper
Plan and execute Convex schema migrations safely, including adding fields, creating tables, and data transformations. Use when schema changes affect existing data.
gsd:sprint
Autonomous phase execution with Codex validation. Runs plan/execute/verify cycles in a bash loop until done or halted.
gsd:second-opinion
Get a second opinion from OpenAI Codex (GPT-5.2-codex) on code, architecture, or implementation decisions. Use when you want independent verification or an alternative perspective.
gsd:milestone-sprint
Run entire milestone autonomously with Codex validation. Auto-detects current milestone, executes all phases, and runs audit.