| name | convex-http-actions |
| displayName | Convex HTTP Actions |
| description | External API integration and webhook handling including HTTP endpoint routing, request/response handling, authentication, CORS configuration, and webhook signature validation |
| version | 1.0.0 |
| author | Convex |
| tags | ["convex","http","actions","webhooks","api","endpoints"] |
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
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
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
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/api/data",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.json();
const authHeader = request.headers.get("Authorization");
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.({
: ,
: ,
: ( (ctx, request) => {
formData = request.();
name = formData.();
email = formData.();
(
.({ name, email }),
{
: ,
: { : },
}
);
}),
});
http.({
: ,
: ,
: ( (ctx, request) => {
bytes = request.();
contentType = request..() ?? ;
blob = ([bytes], { : contentType });
storageId = ctx..(blob);
(
.({ storageId }),
{
: ,
: { : },
}
);
}),
});
http;
Path Parameters
Use path prefix matching for dynamic routes:
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
pathPrefix: "/api/users/",
method: "GET",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
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
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
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",
};
http.route({
path: "/api/data",
method: "OPTIONS",
handler: httpAction(async () => {
return new Response(null, {
status: 204,
headers: corsHeaders,
});
}),
});
http.route({
path: "/api/data",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.json();
return new (
.({ : , : body }),
{
: ,
: {
: ,
...corsHeaders,
},
}
);
}),
});
http;
Webhook Handling
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
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();
try {
await ctx.runAction(internal.stripe.verifyAndProcessWebhook, {
body,
signature,
});
return new Response("OK", { status: 200 });
} catch (error) {
console.(, error);
(, { : });
}
}),
});
http.({
: ,
: ,
: ( (ctx, request) => {
event = request..();
signature = request..();
(!signature) {
(, { : });
}
body = request.();
ctx.(internal.., {
: event ?? ,
body,
signature,
});
(, { : });
}),
});
http;
Webhook Signature Verification
"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.., {
: event...,
: event... ,
});
;
:
ctx.(internal.., {
: event...,
: event...,
});
;
}
;
},
});
Authentication in HTTP Actions
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
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" } }
);
}
const isValid = await ctx.runQuery(internal.auth.validateApiKey, {
apiKey,
});
if (!isValid) {
return new Response(
JSON.({ : }),
{ : , : { : } }
);
}
data = ctx.(internal.., {});
(
.(data),
{ : , : { : } }
);
}),
});
http.({
: ,
: ,
: ( (ctx, request) => {
authHeader = request..();
(!authHeader?.()) {
(
.({ : }),
{ : , : { : } }
);
}
token = authHeader.();
user = ctx.(internal.., { token });
(!user) {
(
.({ : }),
{ : , : { : } }
);
}
(
.(user),
{ : , : { : } }
);
}),
});
http;
Calling Mutations and Queries
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();
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.({
: ,
: ,
: ( (ctx, request) => {
url = (request.);
limit = (url..() ?? );
items = ctx.(internal.., { limit });
(
.(items),
{ : , : { : } }
);
}),
});
http;
Error Handling
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
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 {
const contentType = request.headers.get("Content-Type");
if (!contentType?.()) {
(, );
}
body;
{
body = request.();
} {
(, );
}
(!body.) {
(, );
}
result = ctx.(internal.., {
: body.,
});
({ : , result }, );
} (error) {
.(, error);
(, );
}
}),
});
http;
File Downloads
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">;
const fileUrl = await ctx.storage.getUrl(fileId);
if (!fileUrl) {
return new Response("File not found", { status: 404 });
}
return Response.redirect(fileUrl, 302);
}),
});
export http;
Examples
Complete Webhook Integration
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
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,
});
(, { : });
} (error) {
.(, error);
(, { : });
}
}),
});
http;
"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: <, > };
(event.) {
:
ctx.(internal.., {
: event.. ,
: (event.. <{ : }>)[]?.,
: ,
});
;
:
ctx.(internal.., {
: event.. ,
: (event.. <{ : }>)[]?.,
: ,
});
;
:
ctx.(internal.., {
: event.. ,
});
;
}
;
},
});
Schema for HTTP API
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.(v.()),
})
.(, [])
.(, []),
: ({
: v.(),
: v.(),
: v.(),
}).(, []),
});
Best Practices
- Never run
bunx 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