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.

referenceswebhooks.md

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

Webhook recipes

Provider specific verification for Stripe, Clerk, and Resend, plus replay protection and an idempotency table with status tracking. All routes are added to the router in convex/http.ts.

Shared rules

  • Read the body once with request.text(). Verify against those exact bytes, then JSON.parse.
  • Return 400 for a missing or invalid signature, 200 once the event is recorded, and 5xx only for a transient failure you want the provider to retry.
  • Secrets live in deployment environment variables. Set them with npx convex env set NAME value on dev and in the dashboard for prod.
  • Point the provider at https://<deployment>.convex.site/<path>. Dev and prod have different deployments, so each needs its own webhook endpoint and secret.
  • Dedupe by provider event id. Every provider retries.

Stripe

Stripe's constructEvent uses Node crypto synchronously, so verification runs in an internal Node action. The HTTP action collects the header and raw body and delegates.

// convex/http.ts
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 stripe-signature", { status: 400 });
    const body = await request.text();
    try {
      await ctx.runAction(internal.stripe.handleWebhook, { body, signature });
      return new Response(null, { status: 200 });
    } catch (error) {
      console.error("Stripe webhook rejected", error);
      return new Response("Invalid webhook", { status: 400 });
    }
  }),
});
// convex/stripe.ts
"use node";

import { internalAction } 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 handleWebhook = internalAction({
  args: { body: v.string(), signature: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    // Throws on a bad signature, which the HTTP action turns into a 400
    const event = stripe.webhooks.constructEvent(
      args.body,
      args.signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );

    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;
        await ctx.runMutation(internal.payments.recordCheckout, {
          eventId: event.id,
          sessionId: session.id,
          customerId: typeof session.customer === "string" ? session.customer : "",
        });
        break;
      }
      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        const sub = event.data.object;
        await ctx.runMutation(internal.subscriptions.sync, {
          eventId: event.id,
          subscriptionId: sub.id,
          status: sub.status,
        });
        break;
      }
      default:
        // Unhandled types still return 200 so Stripe stops retrying them
        break;
    }
    return null;
  },
});

Each target mutation checks eventId against the webhookEvents table before writing (see idempotency below). Use the whsec_... value from the endpoint you created in the Stripe dashboard, not the API key.

Clerk

Clerk signs with svix. The svix package runs in the default Convex runtime, so verification can happen directly in http.ts.

// convex/http.ts
import { Webhook } from "svix";
import type { WebhookEvent } from "@clerk/backend";

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 payload = await request.text();
    let event: WebhookEvent;
    try {
      const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
      event = wh.verify(payload, {
        "svix-id": svixId,
        "svix-timestamp": svixTimestamp,
        "svix-signature": svixSignature,
      }) as WebhookEvent;
    } catch (error) {
      console.error("Clerk webhook rejected", error);
      return new Response("Invalid signature", { status: 400 });
    }

    switch (event.type) {
      case "user.created":
      case "user.updated":
        await ctx.runMutation(internal.users.upsertFromClerk, { data: event.data });
        break;
      case "user.deleted":
        if (event.data.id) {
          await ctx.runMutation(internal.users.deleteFromClerk, { clerkId: event.data.id });
        }
        break;
      default:
        break;
    }
    return new Response(null, { status: 200 });
  }),
});
// convex/users.ts
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const upsertFromClerk = internalMutation({
  args: { data: v.any() },
  returns: v.null(),
  handler: async (ctx, { data }) => {
    const clerkId: string = data.id;
    const attrs = {
      clerkId,
      name: `${data.first_name ?? ""} ${data.last_name ?? ""}`.trim(),
      email: data.email_addresses?.[0]?.email_address ?? "",
    };
    const existing = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", clerkId))
      .unique();
    if (existing) {
      await ctx.db.patch(existing._id, attrs);
    } else {
      await ctx.db.insert("users", attrs);
    }
    return null;
  },
});

export const deleteFromClerk = internalMutation({
  args: { clerkId: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", args.clerkId))
      .unique();
    if (user) await ctx.db.delete(user._id);
    return null;
  },
});

The svix verify call also rejects timestamps older than five minutes, so replay protection comes for free. user.updated and user.created share one upsert, which makes redelivery harmless.

Resend

With the @convex-dev/resend component, the component verifies and records the event:

// convex/http.ts
import { resend } from "./sendEmails";

http.route({
  path: "/webhooks/resend",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    return await resend.handleResendEventWebhook(ctx, request);
  }),
});

Set RESEND_WEBHOOK_SECRET in the deployment. Without the component, Resend also signs with svix (svix-id, svix-timestamp, svix-signature), so the Clerk recipe applies with RESEND_WEBHOOK_SECRET and event types like email.delivered and email.bounced.

Generic HMAC with replay protection

The SKILL.md example verifies HMAC-SHA256(secret, body) in hex. Many providers instead sign timestamp + "." + body and send both. Reject stale timestamps to block replays of a captured request.

const TOLERANCE_MS = 5 * 60 * 1000;

async function verifySigned(
  request: Request,
  raw: string,
  secret: string,
  now: number,
): Promise<boolean> {
  const timestamp = request.headers.get("x-timestamp");
  const signature = request.headers.get("x-signature");
  if (!timestamp || !signature) return false;
  if (Math.abs(now - Number(timestamp) * 1000) > TOLERANCE_MS) return false;
  return await verifyHmac(`${timestamp}.${raw}`, signature, secret);
}

Date.now() is fine here because HTTP actions are not cached queries. If the provider sends base64 instead of hex, decode with atob and compare bytes rather than strings.

Idempotency table with status

The minimal table in SKILL.md stores each event once. When you also need to see failures and retry them, track status.

// convex/schema.ts
webhookEvents: defineTable({
  source: v.string(),
  eventId: v.string(),
  type: v.string(),
  payload: v.any(),
  status: v.union(v.literal("received"), v.literal("processed"), v.literal("failed")),
  error: v.optional(v.string()),
})
  .index("by_source_and_event_id", ["source", "eventId"])
  .index("by_status", ["status"]),
// convex/webhooks.ts
import { internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

// Records the event and schedules processing. Safe to call more than once.
export const receive = internalMutation({
  args: { source: v.string(), eventId: v.string(), type: v.string(), payload: v.any() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const seen = await ctx.db
      .query("webhookEvents")
      .withIndex("by_source_and_event_id", (q) =>
        q.eq("source", args.source).eq("eventId", args.eventId),
      )
      .unique();
    if (seen) return null;
    const id = await ctx.db.insert("webhookEvents", { ...args, status: "received" });
    await ctx.scheduler.runAfter(0, internal.webhooks.process, { eventId: id });
    return null;
  },
});

export const process = internalMutation({
  args: { eventId: v.id("webhookEvents") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const event = await ctx.db.get(args.eventId);
    if (!event || event.status === "processed") return null;
    try {
      // Apply side effects based on event.type and event.payload
      await ctx.db.patch(args.eventId, { status: "processed" });
    } catch (error) {
      await ctx.db.patch(args.eventId, {
        status: "failed",
        error: error instanceof Error ? error.message : String(error),
      });
    }
    return null;
  },
});

Failed events sit in the table under by_status and can be reprocessed from the dashboard or a cron without asking the provider to resend.

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.