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.
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 httpverifyProviderSignature 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:
// 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.
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:
- Create a long random value, for example with
openssl rand -base64 32. - Set it as
WEBHOOK_INGEST_SECRETin the Convex deployment. - Set the same value as
NUXT_WEBHOOK_INGEST_SECRETfor Nuxt, and addwebhookIngestSecret: ''toruntimeConfiginnuxt.config.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:
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:
- Check the webhook signature.
- Record the event once in a Convex mutation.
- Schedule a Convex function with
ctx.scheduler.runAfter, or create a job record. - Return the response that the provider expects.
- 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.