All skills
waynesutton avatar

/convex-http-actions

@82d1ce2

Adds HTTP endpoints in convex/http.ts: webhook receivers with signature checks, REST style routes, CORS, auth headers, streaming responses, and file uploads over HTTP. Use when integrating Stripe, Clerk, Resend, or any service that calls back into the app, or when a client needs a plain HTTP API.

Use this Skill: https://skilld.dev/gh/waynesutton/convexskills/convex-http-actions

This session only. Nothing lands on disk.

referencesrest-and-cors.md

≈2.6k tokens on demand. Your agent reads this file only when SKILL.md points to it.

REST style routes, CORS, auth, streaming, and files

Patterns for exposing a plain HTTP API from convex/http.ts: path parameters, the CORS preflight pair, JSON error conventions, bearer and API key auth, streaming responses, and file upload and download routes.

Response helpers

Put these at the bottom of http.ts and use them in every route so headers stay consistent.

const CORS_HEADERS = {
  "Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN ?? "*",
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
  "Access-Control-Max-Age": "86400",
  Vary: "Origin",
};

function json(data: unknown, status = 200, extra: Record<string, string> = {}): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json", ...CORS_HEADERS, ...extra },
  });
}

function error(message: string, status: number): Response {
  return json({ error: message }, status);
}

Set CLIENT_ORIGIN to the exact site origin (https://app.example.com) in production. * is fine for public read only endpoints and never for endpoints that accept credentials.

Path parameters

path matches exactly. For /api/items/<id> use pathPrefix and read the tail. HTTP actions have no ctx.db, so id validation happens in the target query with ctx.db.normalizeId, which returns null for a string that is not a valid id for that table.

// convex/http.ts
http.route({
  pathPrefix: "/api/items/",
  method: "GET",
  handler: httpAction(async (ctx, request) => {
    const id = new URL(request.url).pathname.slice("/api/items/".length);
    if (!id) return error("Missing id", 400);
    const item = await ctx.runQuery(internal.items.getByIdString, { id });
    if (!item) return error("Not found", 404);
    return json(item);
  }),
});
// convex/items.ts
export const getByIdString = internalQuery({
  args: { id: v.string() },
  returns: v.union(v.object({ _id: v.id("items"), _creationTime: v.number(), name: v.string() }), v.null()),
  handler: async (ctx, args) => {
    const id = ctx.db.normalizeId("items", args.id);
    if (!id) return null;
    return await ctx.db.get(id);
  },
});

Query string values come from new URL(request.url).searchParams.get("limit"). Parse and bound them before passing along: Math.min(Number(limit ?? "20"), 100).

CORS preflight pair

Browsers send OPTIONS before any cross origin request with a JSON body or an Authorization header. Register an OPTIONS route for each browser called path. The real route must also return the CORS headers, which json() above already does.

http.route({
  path: "/api/items",
  method: "OPTIONS",
  handler: httpAction(async () => new Response(null, { status: 204, headers: CORS_HEADERS })),
});

http.route({
  path: "/api/items",
  method: "GET",
  handler: httpAction(async (ctx) => {
    const items = await ctx.runQuery(internal.items.listRecent, { limit: 20 });
    return json(items);
  }),
});

If the browser sends cookies or uses credentials: "include", add "Access-Control-Allow-Credentials": "true" and use an exact origin instead of *.

JSON errors and status codes

Situation Status
Body is not valid JSON, or a required field is missing 400
No or malformed Authorization header 401
Valid identity but not allowed to do this 403
Id does not resolve 404
Wrong Content-Type on a JSON route 415
Unexpected exception 500 (log it, return a generic message)
http.route({
  path: "/api/items",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    if (!request.headers.get("Content-Type")?.includes("application/json")) {
      return error("Content-Type must be application/json", 415);
    }
    let body: { name?: unknown };
    try {
      body = await request.json();
    } catch {
      return error("Invalid JSON body", 400);
    }
    if (typeof body.name !== "string" || body.name.length === 0) {
      return error("name is required", 400);
    }
    try {
      const id = await ctx.runMutation(internal.items.create, { name: body.name });
      return json({ id }, 201);
    } catch (e) {
      console.error("POST /api/items failed", e);
      return error("Internal server error", 500);
    }
  }),
});

Never echo the raw exception message to the client. It can include table names or ids.

Auth with a bearer header

If the client can obtain a JWT from your configured auth provider (Clerk, WorkOS AuthKit, Auth0, Convex Auth), send it as Authorization: Bearer <jwt>. Convex validates it against convex/auth.config.ts and ctx.auth.getUserIdentity() returns the identity, exactly as in a query.

http.route({
  path: "/api/me",
  method: "GET",
  handler: httpAction(async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return error("Unauthorized", 401);
    const user = await ctx.runQuery(internal.users.getBySubject, { subject: identity.subject });
    if (!user) return error("Not found", 404);
    return json(user);
  }),
});

For machine clients that cannot get a JWT, issue API keys. Store only a SHA-256 hash and look it up by index; a leaked database dump then reveals nothing usable.

// convex/http.ts
async function sha256Hex(input: string): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
  return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
}

http.route({
  path: "/api/export",
  method: "GET",
  handler: httpAction(async (ctx, request) => {
    const header = request.headers.get("Authorization") ?? "";
    if (!header.startsWith("Bearer ")) return error("Missing API key", 401);
    const keyHash = await sha256Hex(header.slice("Bearer ".length));
    const owner = await ctx.runQuery(internal.apiKeys.ownerForHash, { keyHash });
    if (!owner) return error("Invalid API key", 403);
    const data = await ctx.runQuery(internal.exports.forUser, { userId: owner });
    return json(data);
  }),
});
// convex/apiKeys.ts
export const ownerForHash = internalQuery({
  args: { keyHash: v.string() },
  returns: v.union(v.id("users"), v.null()),
  handler: async (ctx, args) => {
    const key = await ctx.db
      .query("apiKeys")
      .withIndex("by_key_hash", (q) => q.eq("keyHash", args.keyHash))
      .unique();
    if (!key || key.revokedAt !== undefined) return null;
    return key.userId;
  },
});

Schema: apiKeys: defineTable({ keyHash: v.string(), userId: v.id("users"), name: v.string(), revokedAt: v.optional(v.number()) }).index("by_key_hash", ["keyHash"]).

Streaming responses

Return a Response whose body is a ReadableStream. Convex flushes chunks as they are enqueued, which suits server sent events and LLM token streams.

http.route({
  path: "/api/stream",
  method: "GET",
  handler: httpAction(async (ctx, request) => {
    const prompt = new URL(request.url).searchParams.get("q") ?? "";
    const encoder = new TextEncoder();
    const stream = new ReadableStream({
      async start(controller) {
        // Replace with a real token source, such as an LLM SDK stream
        for (const chunk of await ctx.runAction(internal.ai.tokensFor, { prompt })) {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
        }
        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
        controller.close();
      },
    });
    return new Response(stream, {
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        ...CORS_HEADERS,
      },
    });
  }),
});

The whole handler still has to finish within the action time limit, so a stream is for delivering results progressively, not for keeping a connection open indefinitely. For long lived realtime data, use a Convex query subscription instead.

File download and upload over HTTP

Serve a stored file by reading the blob with ctx.storage.get and returning it. This keeps the .convex.site URL stable and lets you add auth or custom headers. Redirecting to ctx.storage.getUrl also works when you do not need to gate access.

// convex/http.ts
import { Id } from "./_generated/dataModel";

http.route({
  pathPrefix: "/files/",
  method: "GET",
  handler: httpAction(async (ctx, request) => {
    const storageId = new URL(request.url).pathname.slice("/files/".length) as Id<"_storage">;
    if (!storageId) return error("Not found", 404);
    const blob = await ctx.storage.get(storageId);
    if (!blob) return error("Not found", 404);
    return new Response(blob, {
      headers: {
        "Content-Type": blob.type || "application/octet-stream",
        "Cache-Control": "public, max-age=3600",
        ...CORS_HEADERS,
      },
    });
  }),
});

To gate access, look up the file's owner first with ctx.runQuery(internal.files.ownerOf, { storageId }) and compare against ctx.auth.getUserIdentity() before reading the blob.

Accept an upload by storing the request body directly. The 20MB request limit applies, so browsers uploading larger files should use ctx.storage.generateUploadUrl() from a mutation instead.

http.route({
  path: "/upload",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return error("Unauthorized", 401);
    const contentType = request.headers.get("Content-Type") ?? "";
    if (!contentType.startsWith("image/")) return error("Only images are accepted", 415);

    const blob = await request.blob();
    const storageId = await ctx.storage.store(blob);
    await ctx.runMutation(internal.files.save, {
      storageId,
      subject: identity.subject,
      contentType,
      size: blob.size,
    });
    return json({ storageId }, 201);
  }),
});

http.route({
  path: "/upload",
  method: "OPTIONS",
  handler: httpAction(async () => new Response(null, { status: 204, headers: CORS_HEADERS })),
});

request.blob() keeps the Content-Type from the request, so ctx.storage.store(blob) records it as the file's content type and ctx.storage.getUrl serves it with the right header later.

Source: SKILL.md on GitHub

No alerts16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    This skill provides safe and well-documented templates for implementing HTTP actions and webhook handling in Convex applications. It emphasizes security best practices such as signature verification and environment variable usage.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    2 files scanned · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 82d1ce2. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 4 days ago
  • API
  • convex
  • http
  • webhooks
  • endpoints
  • authentication
  • cors
  • stripe
  • github

README badge

README badge for waynesutton/convexskills/convex-http-actions

Implements HTTP endpoints in Convex for webhooks, external API integrations, and custom routes. Covers request/response handling, path parameters, CORS configuration, webhook signature validation, authentication, and mutation/query calls from HTTP handlers.

Generated from the current SKILL.md.

Can I use HTTP actions to receive webhooks from third-party services?
Yes. HTTP actions are designed for webhook handling from services like Stripe and GitHub. The skill includes examples for signature verification and event processing.
Does this skill cover CORS configuration?
Yes. The skill provides examples for setting CORS headers, handling OPTIONS preflight requests, and configuring allowed origins and methods.
How do I authenticate requests to HTTP actions?
The skill covers API key authentication via custom headers and Bearer token authentication via the Authorization header, with examples for validation against stored credentials.
Can HTTP actions call mutations and queries?
Yes. HTTP actions can call both mutations and queries using ctx.runMutation and ctx.runQuery to interact with your Convex database.

Generated from the current SKILL.md. These answers refresh after source changes.