Skip to main content

Webhooks and jobs

Verify external events before you write to Convex, and keep request handlers apart from long work.

A webhook handler checks the signature of the external sender. A Convex mutation then records the event once and schedules the long work.

Warning: Never expose the webhook mutation as a public Convex function without its own check. Any browser or Convex client can call a public mutation directly and skip the signature check in your route.

Receive the webhook in a Convex HTTP action

This is the recommended setup. The HTTP action checks the signature and calls an internal mutation. Clients cannot call an internal mutation.

convex/http.ts
import { httpRouter } from 'convex/server'

import { internal } from './_generated/api'
import { httpAction } from './_generated/server'
import { auth } from './auth'

const http = httpRouter()
auth.registerRoutes(http)

http.route({
  path: '/webhooks/provider',
  method: 'POST',
  handler: httpAction(async (ctx, request) => {
    const rawBody = await request.text()
    const signature = request.headers.get('provider-signature')

    if (!signature || !(await verifyProviderSignature(rawBody, signature))) {
      return new Response('Invalid signature', { status: 401 })
    }

    const payload = parseProviderEvent(rawBody)
    await ctx.runMutation(internal.webhooks.receive, {
      providerEventId: payload.id,
      type: payload.type,
    })
    return Response.json({ accepted: true })
  }),
})

export default http

verifyProviderSignature and parseProviderEvent stand for the signature check and parser of your provider. Check the signature before you parse the body. Give the provider the URL https://<deployment>.convex.site/webhooks/provider.

Make the mutation idempotent

Store the provider event ID with an index:

convex/schema.ts
// A property inside the existing defineSchema({ ... }).
webhookEvents: defineTable({
  providerEventId: v.string(),
  type: v.string(),
  receivedAt: v.number(),
}).index('by_provider_event', ['providerEventId']),

If the provider sends the same event again, return the existing record. Do not apply the change twice.

convex/webhooks.ts
import { v } from 'convex/values'

import { internalMutation } from './_generated/server'

export const receive = internalMutation({
  args: { providerEventId: v.string(), type: v.string() },
  handler: async (ctx, args) => {
    const existing = await ctx.db
      .query('webhookEvents')
      .withIndex('by_provider_event', (q) => q.eq('providerEventId', args.providerEventId))
      .unique()
    if (existing) return existing._id

    return await ctx.db.insert('webhookEvents', { ...args, receivedAt: Date.now() })
  },
})

Receive the webhook in a Nuxt route

Use a Nuxt server route only when the signature check must run in Nuxt. A Nuxt route cannot call an internal mutation, so the mutation must be public. Protect it with a server-only secret:

  1. Create a long random value, for example with openssl rand -base64 32.
  2. Set it as WEBHOOK_INGEST_SECRET in the Convex deployment.
  3. Set the same value as NUXT_WEBHOOK_INGEST_SECRET for Nuxt, and add webhookIngestSecret: '' to runtimeConfig in nuxt.config.ts.
server/api/webhooks/provider.post.ts
import { api } from '#convex/api'
import { serverConvex } from '#convex/server'

export default defineEventHandler(async (event) => {
  const rawBody = await readRawBody(event)
  const signature = getHeader(event, 'provider-signature')

  if (!rawBody || !signature || !verifyProviderSignature(rawBody, signature)) {
    throw createError({ status: 401, statusText: 'Invalid signature' })
  }

  const payload = parseProviderEvent(rawBody)

  await serverConvex(event, { auth: 'none' }).mutation(api.webhooks.receive, {
    secret: useRuntimeConfig(event).webhookIngestSecret,
    providerEventId: payload.id,
    type: payload.type,
  })

  return { accepted: true }
})

auth: 'none' means that the Convex call does not use the browser session. It does not prove that the call comes from your route. The secret does that. The public mutation compares it in constant time before it writes:

convex/webhooks.ts
import { ConvexError, v } from 'convex/values'

import { mutation } from './_generated/server'

function sameSecret(received: string, expected: string): boolean {
  if (received.length !== expected.length) return false
  let difference = 0
  for (let index = 0; index < received.length; index++) {
    difference |= received.charCodeAt(index) ^ expected.charCodeAt(index)
  }
  return difference === 0
}

export const receive = mutation({
  args: { secret: v.string(), providerEventId: v.string(), type: v.string() },
  handler: async (ctx, { secret, ...event }) => {
    const expected = process.env.WEBHOOK_INGEST_SECRET
    if (!expected || !sameSecret(secret, expected)) {
      throw new ConvexError({ code: 'FORBIDDEN', message: 'Invalid webhook secret' })
    }

    const existing = await ctx.db
      .query('webhookEvents')
      .withIndex('by_provider_event', (q) => q.eq('providerEventId', event.providerEventId))
      .unique()
    if (existing) return existing._id

    return await ctx.db.insert('webhookEvents', { ...event, receivedAt: Date.now() })
  },
})

Never send the secret to the browser. Keep it out of runtimeConfig.public.

Keep long work out of the request

Nuxt route handlers and HTTP actions are not job queues. For longer work:

  1. Check the webhook signature.
  2. Record the event once in a Convex mutation.
  3. Schedule a Convex function with ctx.scheduler.runAfter, or create a job record.
  4. Return the response that the provider expects.
  5. Show job progress through a query if users need it.

Who retries

  • The provider sends the webhook again according to its own rules.
  • The Convex mutation makes sure that the same event is applied once.
  • Scheduled work keeps its own retry state.
  • The webhook handler does not repeat a write when it does not know whether the write finished.