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:
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
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4
5const http = httpRouter();
6
7// Simple GET endpoint
8http.route({
9 path: "/health",
10 method: "GET",
11 handler: httpAction(async (ctx, request) => {
12 return new Response(JSON.stringify({ status: "ok" }), {
13 status: 200,
14 headers: { "Content-Type": "application/json" },
15 });
16 }),
17});
18
19export default http;
Request Handling
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4
5const http = httpRouter();
6
7// Handle JSON body
8http.route({
9 path: "/api/data",
10 method: "POST",
11 handler: httpAction(async (ctx, request) => {
12 // Parse JSON body
13 const body = await request.json();
14
15 // Access headers
16 const authHeader = request.headers.get("Authorization");
17
18 // Access URL parameters
19 const url = new URL(request.url);
20 const queryParam = url.searchParams.get("filter");
21
22 return new Response(
23 JSON.stringify({ received: body, filter: queryParam }),
24 {
25 status: 200,
26 headers: { "Content-Type": "application/json" },
27 }
28 );
29 }),
30});
31
32// Handle form data
33http.route({
34 path: "/api/form",
35 method: "POST",
36 handler: httpAction(async (ctx, request) => {
37 const formData = await request.formData();
38 const name = formData.get("name");
39 const email = formData.get("email");
40
41 return new Response(
42 JSON.stringify({ name, email }),
43 {
44 status: 200,
45 headers: { "Content-Type": "application/json" },
46 }
47 );
48 }),
49});
50
51// Handle raw bytes
52http.route({
53 path: "/api/upload",
54 method: "POST",
55 handler: httpAction(async (ctx, request) => {
56 const bytes = await request.bytes();
57 const contentType = request.headers.get("Content-Type") ?? "application/octet-stream";
58
59 // Store in Convex storage
60 const blob = new Blob([bytes], { type: contentType });
61 const storageId = await ctx.storage.store(blob);
62
63 return new Response(
64 JSON.stringify({ storageId }),
65 {
66 status: 200,
67 headers: { "Content-Type": "application/json" },
68 }
69 );
70 }),
71});
72
73export default http;
Path Parameters
Use path prefix matching for dynamic routes:
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4
5const http = httpRouter();
6
7// Match /api/users/* with pathPrefix
8http.route({
9 pathPrefix: "/api/users/",
10 method: "GET",
11 handler: httpAction(async (ctx, request) => {
12 const url = new URL(request.url);
13 // Extract user ID from path: /api/users/123 -> "123"
14 const userId = url.pathname.replace("/api/users/", "");
15
16 return new Response(
17 JSON.stringify({ userId }),
18 {
19 status: 200,
20 headers: { "Content-Type": "application/json" },
21 }
22 );
23 }),
24});
25
26export default http;
CORS Configuration
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4
5const http = httpRouter();
6
7// CORS headers helper
8const corsHeaders = {
9 "Access-Control-Allow-Origin": "*",
10 "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
11 "Access-Control-Allow-Headers": "Content-Type, Authorization",
12 "Access-Control-Max-Age": "86400",
13};
14
15// Handle preflight requests
16http.route({
17 path: "/api/data",
18 method: "OPTIONS",
19 handler: httpAction(async () => {
20 return new Response(null, {
21 status: 204,
22 headers: corsHeaders,
23 });
24 }),
25});
26
27// Actual endpoint with CORS
28http.route({
29 path: "/api/data",
30 method: "POST",
31 handler: httpAction(async (ctx, request) => {
32 const body = await request.json();
33
34 return new Response(
35 JSON.stringify({ success: true, data: body }),
36 {
37 status: 200,
38 headers: {
39 "Content-Type": "application/json",
40 ...corsHeaders,
41 },
42 }
43 );
44 }),
45});
46
47export default http;
Webhook Handling
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4import { internal } from "./_generated/api";
5
6const http = httpRouter();
7
8// Stripe webhook
9http.route({
10 path: "/webhooks/stripe",
11 method: "POST",
12 handler: httpAction(async (ctx, request) => {
13 const signature = request.headers.get("stripe-signature");
14 if (!signature) {
15 return new Response("Missing signature", { status: 400 });
16 }
17
18 const body = await request.text();
19
20 // Verify webhook signature (in action with Node.js)
21 try {
22 await ctx.runAction(internal.stripe.verifyAndProcessWebhook, {
23 body,
24 signature,
25 });
26 return new Response("OK", { status: 200 });
27 } catch (error) {
28 console.error("Webhook error:", error);
29 return new Response("Webhook error", { status: 400 });
30 }
31 }),
32});
33
34// GitHub webhook
35http.route({
36 path: "/webhooks/github",
37 method: "POST",
38 handler: httpAction(async (ctx, request) => {
39 const event = request.headers.get("X-GitHub-Event");
40 const signature = request.headers.get("X-Hub-Signature-256");
41
42 if (!signature) {
43 return new Response("Missing signature", { status: 400 });
44 }
45
46 const body = await request.text();
47
48 await ctx.runAction(internal.github.processWebhook, {
49 event: event ?? "unknown",
50 body,
51 signature,
52 });
53
54 return new Response("OK", { status: 200 });
55 }),
56});
57
58export default http;
Webhook Signature Verification
typescript
1// convex/stripe.ts
2"use node";
3
4import { internalAction, internalMutation } from "./_generated/server";
5import { internal } from "./_generated/api";
6import { v } from "convex/values";
7import Stripe from "stripe";
8
9const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
10
11export const verifyAndProcessWebhook = internalAction({
12 args: {
13 body: v.string(),
14 signature: v.string(),
15 },
16 returns: v.null(),
17 handler: async (ctx, args) => {
18 const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
19
20 // Verify signature
21 const event = stripe.webhooks.constructEvent(
22 args.body,
23 args.signature,
24 webhookSecret
25 );
26
27 // Process based on event type
28 switch (event.type) {
29 case "checkout.session.completed":
30 await ctx.runMutation(internal.payments.handleCheckoutComplete, {
31 sessionId: event.data.object.id,
32 customerId: event.data.object.customer as string,
33 });
34 break;
35
36 case "customer.subscription.updated":
37 await ctx.runMutation(internal.subscriptions.handleUpdate, {
38 subscriptionId: event.data.object.id,
39 status: event.data.object.status,
40 });
41 break;
42 }
43
44 return null;
45 },
46});
Authentication in HTTP Actions
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4import { internal } from "./_generated/api";
5
6const http = httpRouter();
7
8// API key authentication
9http.route({
10 path: "/api/protected",
11 method: "GET",
12 handler: httpAction(async (ctx, request) => {
13 const apiKey = request.headers.get("X-API-Key");
14
15 if (!apiKey) {
16 return new Response(
17 JSON.stringify({ error: "Missing API key" }),
18 { status: 401, headers: { "Content-Type": "application/json" } }
19 );
20 }
21
22 // Validate API key
23 const isValid = await ctx.runQuery(internal.auth.validateApiKey, {
24 apiKey,
25 });
26
27 if (!isValid) {
28 return new Response(
29 JSON.stringify({ error: "Invalid API key" }),
30 { status: 403, headers: { "Content-Type": "application/json" } }
31 );
32 }
33
34 // Process authenticated request
35 const data = await ctx.runQuery(internal.data.getProtectedData, {});
36
37 return new Response(
38 JSON.stringify(data),
39 { status: 200, headers: { "Content-Type": "application/json" } }
40 );
41 }),
42});
43
44// Bearer token authentication
45http.route({
46 path: "/api/user",
47 method: "GET",
48 handler: httpAction(async (ctx, request) => {
49 const authHeader = request.headers.get("Authorization");
50
51 if (!authHeader?.startsWith("Bearer ")) {
52 return new Response(
53 JSON.stringify({ error: "Missing or invalid Authorization header" }),
54 { status: 401, headers: { "Content-Type": "application/json" } }
55 );
56 }
57
58 const token = authHeader.slice(7);
59
60 // Validate token and get user
61 const user = await ctx.runQuery(internal.auth.validateToken, { token });
62
63 if (!user) {
64 return new Response(
65 JSON.stringify({ error: "Invalid token" }),
66 { status: 403, headers: { "Content-Type": "application/json" } }
67 );
68 }
69
70 return new Response(
71 JSON.stringify(user),
72 { status: 200, headers: { "Content-Type": "application/json" } }
73 );
74 }),
75});
76
77export default http;
Calling Mutations and Queries
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4import { api, internal } from "./_generated/api";
5
6const http = httpRouter();
7
8http.route({
9 path: "/api/items",
10 method: "POST",
11 handler: httpAction(async (ctx, request) => {
12 const body = await request.json();
13
14 // Call a mutation
15 const itemId = await ctx.runMutation(internal.items.create, {
16 name: body.name,
17 description: body.description,
18 });
19
20 // Query the created item
21 const item = await ctx.runQuery(internal.items.get, { id: itemId });
22
23 return new Response(
24 JSON.stringify(item),
25 { status: 201, headers: { "Content-Type": "application/json" } }
26 );
27 }),
28});
29
30http.route({
31 path: "/api/items",
32 method: "GET",
33 handler: httpAction(async (ctx, request) => {
34 const url = new URL(request.url);
35 const limit = parseInt(url.searchParams.get("limit") ?? "10");
36
37 const items = await ctx.runQuery(internal.items.list, { limit });
38
39 return new Response(
40 JSON.stringify(items),
41 { status: 200, headers: { "Content-Type": "application/json" } }
42 );
43 }),
44});
45
46export default http;
Error Handling
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4
5const http = httpRouter();
6
7// Helper for JSON responses
8function jsonResponse(data: unknown, status = 200) {
9 return new Response(JSON.stringify(data), {
10 status,
11 headers: { "Content-Type": "application/json" },
12 });
13}
14
15// Helper for error responses
16function errorResponse(message: string, status: number) {
17 return jsonResponse({ error: message }, status);
18}
19
20http.route({
21 path: "/api/process",
22 method: "POST",
23 handler: httpAction(async (ctx, request) => {
24 try {
25 // Validate content type
26 const contentType = request.headers.get("Content-Type");
27 if (!contentType?.includes("application/json")) {
28 return errorResponse("Content-Type must be application/json", 415);
29 }
30
31 // Parse body
32 let body;
33 try {
34 body = await request.json();
35 } catch {
36 return errorResponse("Invalid JSON body", 400);
37 }
38
39 // Validate required fields
40 if (!body.data) {
41 return errorResponse("Missing required field: data", 400);
42 }
43
44 // Process request
45 const result = await ctx.runMutation(internal.process.handle, {
46 data: body.data,
47 });
48
49 return jsonResponse({ success: true, result }, 200);
50 } catch (error) {
51 console.error("Processing error:", error);
52 return errorResponse("Internal server error", 500);
53 }
54 }),
55});
56
57export default http;
File Downloads
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4import { Id } from "./_generated/dataModel";
5
6const http = httpRouter();
7
8http.route({
9 pathPrefix: "/files/",
10 method: "GET",
11 handler: httpAction(async (ctx, request) => {
12 const url = new URL(request.url);
13 const fileId = url.pathname.replace("/files/", "") as Id<"_storage">;
14
15 // Get file URL from storage
16 const fileUrl = await ctx.storage.getUrl(fileId);
17
18 if (!fileUrl) {
19 return new Response("File not found", { status: 404 });
20 }
21
22 // Redirect to the file URL
23 return Response.redirect(fileUrl, 302);
24 }),
25});
26
27export default http;
Examples
Complete Webhook Integration
typescript
1// convex/http.ts
2import { httpRouter } from "convex/server";
3import { httpAction } from "./_generated/server";
4import { internal } from "./_generated/api";
5
6const http = httpRouter();
7
8// Clerk webhook for user sync
9http.route({
10 path: "/webhooks/clerk",
11 method: "POST",
12 handler: httpAction(async (ctx, request) => {
13 const svixId = request.headers.get("svix-id");
14 const svixTimestamp = request.headers.get("svix-timestamp");
15 const svixSignature = request.headers.get("svix-signature");
16
17 if (!svixId || !svixTimestamp || !svixSignature) {
18 return new Response("Missing Svix headers", { status: 400 });
19 }
20
21 const body = await request.text();
22
23 try {
24 await ctx.runAction(internal.clerk.verifyAndProcess, {
25 body,
26 svixId,
27 svixTimestamp,
28 svixSignature,
29 });
30 return new Response("OK", { status: 200 });
31 } catch (error) {
32 console.error("Clerk webhook error:", error);
33 return new Response("Webhook verification failed", { status: 400 });
34 }
35 }),
36});
37
38export default http;
typescript
1// convex/clerk.ts
2"use node";
3
4import { internalAction, internalMutation } from "./_generated/server";
5import { internal } from "./_generated/api";
6import { v } from "convex/values";
7import { Webhook } from "svix";
8
9export const verifyAndProcess = internalAction({
10 args: {
11 body: v.string(),
12 svixId: v.string(),
13 svixTimestamp: v.string(),
14 svixSignature: v.string(),
15 },
16 returns: v.null(),
17 handler: async (ctx, args) => {
18 const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;
19 const wh = new Webhook(webhookSecret);
20
21 const event = wh.verify(args.body, {
22 "svix-id": args.svixId,
23 "svix-timestamp": args.svixTimestamp,
24 "svix-signature": args.svixSignature,
25 }) as { type: string; data: Record<string, unknown> };
26
27 switch (event.type) {
28 case "user.created":
29 await ctx.runMutation(internal.users.create, {
30 clerkId: event.data.id as string,
31 email: (event.data.email_addresses as Array<{ email_address: string }>)[0]?.email_address,
32 name: `${event.data.first_name} ${event.data.last_name}`,
33 });
34 break;
35
36 case "user.updated":
37 await ctx.runMutation(internal.users.update, {
38 clerkId: event.data.id as string,
39 email: (event.data.email_addresses as Array<{ email_address: string }>)[0]?.email_address,
40 name: `${event.data.first_name} ${event.data.last_name}`,
41 });
42 break;
43
44 case "user.deleted":
45 await ctx.runMutation(internal.users.remove, {
46 clerkId: event.data.id as string,
47 });
48 break;
49 }
50
51 return null;
52 },
53});
Schema for HTTP API
typescript
1// convex/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v } from "convex/values";
4
5export default defineSchema({
6 apiKeys: defineTable({
7 key: v.string(),
8 userId: v.id("users"),
9 name: v.string(),
10 createdAt: v.number(),
11 lastUsedAt: v.optional(v.number()),
12 revokedAt: v.optional(v.number()),
13 })
14 .index("by_key", ["key"])
15 .index("by_user", ["userId"]),
16
17 webhookEvents: defineTable({
18 source: v.string(),
19 eventType: v.string(),
20 payload: v.any(),
21 processedAt: v.number(),
22 status: v.union(
23 v.literal("success"),
24 v.literal("failed")
25 ),
26 error: v.optional(v.string()),
27 })
28 .index("by_source", ["source"])
29 .index("by_status", ["status"]),
30
31 users: defineTable({
32 clerkId: v.string(),
33 email: v.string(),
34 name: v.string(),
35 }).index("by_clerk_id", ["clerkId"]),
36});
Best Practices
- Never run
npx convex deploy unless 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