Agent Skills
Discover and share powerful Agent Skills for AI assistants
convex-http-actions - Agent Skill - Agent Skills
Home/ Skills / convex-http-actions External API integration and webhook handling including HTTP endpoint routing, request/response handling, authentication, CORS configuration, and webhook signature validation
Use the skills CLI to install this skill with one command. Auto-detects all installed AI assistants.
Method 1 - skills CLI
npx skills i waynesutton/convexskills/skills/convex-http-actions CopyMethod 2 - openskills (supports sync & update)
npx openskills install waynesutton/convexskills CopyAuto-detects Claude Code, Cursor, Codex CLI, Gemini CLI, and more. One install, works everywhere.
Installation Path
Download and extract to one of the following locations:
Claude Code Cursor OpenCode Gemini CLI Codex CLI
~/.claude/skills/convex-http-actions/ Back No setup needed. Let our cloud agents run this skill for you.
Select Model
Claude Haiku 4.5 $0.10 Claude Sonnet 4.5 $0.20 Claude Opus 4.5 $0.50 Claude Sonnet 4.5 $0.20 /task
Best for coding tasks
Try NowEnvironment setup included
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
// 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 (
Request Handling
// convex/http.ts
import { httpRouter } from "convex/server" ;
import { httpAction } from "./_generated/server" ;
const 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" ,
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"
Webhook Handling
// convex/http.ts
import { httpRouter } from "convex/server" ;
import { httpAction } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
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
Authentication in HTTP Actions
// convex/http.ts
import { httpRouter } from "convex/server" ;
import { httpAction } from
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 ();
Error Handling
// convex/http.ts
import { httpRouter } from "convex/server" ;
import { httpAction } from "./_generated/server" ;
const http = httpRouter ();
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/" ,
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
// convex/clerk.ts
"use node" ;
import { internalAction, internalMutation } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
import
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 (
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
ctx
,
request
)
=>
{
return new Response ( JSON . stringify ({ status: "ok" }), {
status: 200 ,
headers: { "Content-Type" : "application/json" },
});
}),
});
export default 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;
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;
:
"*"
,
"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;
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;
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 ;
},
});
"./_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;
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;
// 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;
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;
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;
{ 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 ;
},
});
"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" ]),
});