---
title: "Webhooks"
description: "One call mounts every inbound route — auth routes, billing and email webhooks, the metered AI stream, and the agent token exchange — with signature verification built in."
canonical_url: "https://nuxt-backend.dev/docs/ship/production/webhooks"
---
# Webhooks

> One call mounts every inbound route — auth routes, billing and email webhooks, the metered AI stream, and the agent token exchange — with signature verification built in.

`registerBackendRoutes(http, options)` from `nuxt-backend/http` is the whole `http.ts` story: one call mounts every inbound route the backend handles on your Convex HTTP router — the auth routes, the two webhooks, the metered AI stream endpoint, and the agent token exchange. Signature verification stays where it already lives — the auth routes, the billing component (`BILLING_WEBHOOK_SECRET`), and the email component (`EMAIL_WEBHOOK_SECRET`) — this is composition, not re-implementation. Each option takes the instance the matching `setup*` helper returns (the scaffolded `http.ts`, its comment left out):

```ts [backend/http.ts]
import { registerBackendRoutes } from 'nuxt-backend/http'
import { httpRouter } from 'convex/server'
import { ai } from './ai'
import { authComponent, createAuth, mcp } from './auth'
import { billing } from './billing'
import { email } from './email'

const http = httpRouter()
registerBackendRoutes(http, {
  auth: { authComponent, createAuth, mcp },
  billing,
  email,
  ai,
})

export default http
```

## What gets mounted

<table>
<thead>
  <tr>
    <th>
      Route
    </th>
    
    <th>
      What it does
    </th>
    
    <th>
      Verified with
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Auth routes
    </td>
    
    <td>
      The Better Auth HTTP endpoints (same as <code>
        authComponent.registerRoutes
      </code>
      
      )
    </td>
    
    <td>
      Auth's own signing
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /billing/events
      </code>
      
       (POST)
    </td>
    
    <td>
      Refreshes the reactive entitlement cache (subscriptions, benefit grants, credit balances) and fulfils <a href="https://nuxt-backend.dev/docs/billing/credits/gifts">
        gift purchases
      </a>
    </td>
    
    <td>
      <code>
        BILLING_WEBHOOK_SECRET
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /email/events
      </code>
      
       (POST)
    </td>
    
    <td>
      Tracks delivery status (delivered / bounced / complained) behind <code>
        useEmailStatus
      </code>
    </td>
    
    <td>
      <code>
        EMAIL_WEBHOOK_SECRET
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /ai/stream
      </code>
      
       (POST + OPTIONS)
    </td>
    
    <td>
      Runs an <code>
        ai.stream(...)
      </code>
      
       handler and streams its chunks to <code>
        useAiStream
      </code>
      
       — the OPTIONS route answers the browser's CORS preflight for the app origin (<code>
        SITE_URL
      </code>
      
      )
    </td>
    
    <td>
      The <code>
        streamId
      </code>
      
       the authenticated <code>
        start
      </code>
      
       action just handed the caller (credits already reserved)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /mcp/exchange
      </code>
      
       (POST)
    </td>
    
    <td>
      The agent token exchange behind the app's <code>
        /mcp
      </code>
      
       endpoint — trades an agent's OAuth bearer for a short-lived Convex JWT
    </td>
    
    <td>
      The bearer is looked up in the auth provider's token store (<code>
        401
      </code>
      
       otherwise)
    </td>
  </tr>
</tbody>
</table>

Both webhooks share one fail-closed policy: missing secret → `503`, invalid signature → `403`, oversized body → `413`, authentic-but-unknown event type → `202`, handler throw → `500` (so the provider retries), redelivery of a processed id → `200`. Every delivery's outcome lands in the component's [delivery log](#the-delivery-log) (the Backend tab in Nuxt DevTools and the playground feed read it). Both routes run through one edge, so the policy cannot drift between them. The size cap is 1 MiB, counted in bytes: a `Content-Length` over it is refused before the body is read. A refused request also leaves a `webhook.rejected` line in the deployment log, with the route, the outcome and the status, once per isolate for each route and outcome so junk traffic cannot flood the log; a missing secret leaves `webhook.secret_missing` instead. [Troubleshooting](https://nuxt-backend.dev/docs/ship/operate/troubleshooting#log-codes) says what to do about each.

## Point the provider dashboards at them

Webhooks arrive on your backend's **site** URL — the `.convex.site` domain, not `.convex.cloud` (inside the deployment the platform runtime exposes it as `CONVEX_SITE_URL`):

- **Polar dashboard** → Settings → Webhooks: `https://your-deployment.convex.site/billing/events`, with the same secret as `BILLING_WEBHOOK_SECRET`.
- **Resend dashboard** → Webhooks: `https://your-deployment.convex.site/email/events`, with the signing secret as `EMAIL_WEBHOOK_SECRET`.

## Reacting to events

The built-in handlers keep state fresh; your hooks run on top of them, per event.

Billing hooks go on `setupBilling` and run after the built-in entitlement refresh: see [React to billing events](https://nuxt-backend.dev/docs/billing/events).

Email hooks go on [`setupEmail`](https://nuxt-backend.dev/docs/email/sending), keyed by the provider's event names too (`'email.delivered'`, `'email.bounced'`, … — the full catalog is on [Delivery tracking](https://nuxt-backend.dev/docs/email/delivery-tracking#reacting-to-events)), and fire after the event is verified and the status recorded:

```ts [backend/email.ts]
export const email = setupEmail(components, {
  events: {
    'email.delivered': async (ctx, event) => { /* … */ },
    'email.bounced': async (ctx, event) => { /* suppress future sends to event.data.to */ },
    'email.complained': async (ctx, event) => { /* alert an admin */ },
  },
})
```

## The delivery log

Redelivery dedupe reads the log, and how it keeps an outcome depends on whether the request proved who sent it:

- A delivery whose signature verified (`ok`, `unknown_type`, `handler_error`) gets a row of its own. The newest 200 are kept: older rows are trimmed in the background, at most once an hour, so recording a delivery never reads the rest of the log and concurrent deliveries never wait on each other.
- A request answered before any signature verified (`missing_secret`, `oversized`, `invalid_signature`, or a `duplicate` of a processed id) is counted per route and outcome instead, under the latest one's delivery id. Anyone can POST to these routes, so a flood of junk can neither grow the log nor push out the verified rows that dedupe relies on. A count starts afresh after a day without one.

Delivery ids and event types are kept to their first 128 characters, a handler's error note to its first 500.

The log spans every account on the deployment, so `getWebhookDeliveries` (re-exported by the scaffolded `billing.ts`) is admin-tier, like `refundOrder`: it answers a caller `setupBilling({ requireAdmin })` admits (by default, one with the `admin` role) and `null` to anyone else, signed out included. The DevTools Backend tab says so instead of showing an empty log.

## Custom paths

Change where the routes mount with `billingPath` / `emailPath` / `aiPath` / `mcpExchangePath` (update the provider dashboards to match; the Nuxt side follows `SITE_URL` + the module's `mcp.route`):

```ts [backend/http.ts]
registerBackendRoutes(http, {
  auth: { authComponent, createAuth, mcp },
  billing,
  email,
  ai,
  billingPath: '/hooks/billing',
  emailPath: '/hooks/email',
})
```

<note>

Every part is required. A route left out would answer `404` the first time a provider or a client calls it, so the type refuses the call instead. For fully custom routing, skip `registerBackendRoutes` and mount each part on your own router: `authComponent.registerRoutes(http, createAuth)` for the auth routes; each instance's `webhookHandler` (`billing`, `email`) and the exchange's `exchangeHandler` (`mcp`), wrapped in `httpActionGeneric` from `convex/server`; and `ai.httpHandler`, already an HTTP action, beside an `OPTIONS` route that answers its CORS preflight.

</note>

<page-keys>

- Try it live in the playground: [/playground/platform/webhooks](https://nuxt-backend.dev/playground/platform/webhooks)
- API reference: [/docs/reference/api/http](https://nuxt-backend.dev/docs/reference/api/http)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/convex/components/backend/webhooks.ts](https://github.com/qruto/nuxt-backend/blob/main/src/convex/components/backend/webhooks.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
