# Process webhooks (/docs/integrations/convex/webhooks)



Provider webhooks are public HTTP endpoints. The application owns the URL and signature check; the component owns deduplication, message correlation, event history, and normalized delivery state.

## Register a route [#register-a-route]

```ts title="convex/http.ts"
import { httpRouter } from "convex/server";
import { email } from "./email";
import { verifyResendWebhook } from "./webhookVerification";

const http = httpRouter();

email.registerRoutes(http, {
  pathPrefix: "/email",
  providers: ["resend"],
  verify: async ({ body, headers }) =>
    verifyResendWebhook({
      body,
      headers,
      secret: process.env.RESEND_WEBHOOK_SECRET!,
    }),
});

export default http;
```

This registers `POST /email/webhooks/resend` on your deployment's `.convex.site` domain.

<Callout type="warn" title="Never omit verification in production">
  `verify` receives the provider name, original `Request`, raw body, and normalized headers. Verify the raw request with the provider's official algorithm before the component sees it. A shared header comparison is acceptable for a private development tunnel, not for an internet-facing production endpoint.
</Callout>

## What the component stores [#what-the-component-stores]

Every accepted delivery receives a stable provider delivery id. Replayed webhook requests return successfully without creating a duplicate event.

When `providerMessageId` matches a stored send, the component appends a `webhook` event and updates normalized state:

| Provider event                | Stored result                                   |
| ----------------------------- | ----------------------------------------------- |
| Delivered                     | `deliveryStatus: "delivered"` and `deliveredAt` |
| Permanent bounce or rejection | `deliveryStatus: "bounced"`                     |
| Spam complaint                | `deliveryStatus: "complained"`                  |
| Temporary failure             | Event history only                              |
| Open, click, or unknown event | Event history only                              |

Bounces and complaints remain sticky against a late delivered event. When both a bounce and complaint arrive, the latest terminal webhook wins.

## Supported payload shapes [#supported-payload-shapes]

The current normalizer understands Resend, Postmark, and Mailgun-style payloads. Other providers are stored through the generic event path when they expose a provider message id and event name, but you should test their exact payload shape before relying on normalized `deliveryStatus`.

## Test locally [#test-locally]

1. Send through a provider test address and capture the stored `providerMessageId`.
2. POST a signed test webhook to the route.
3. Query `email.status` and `email.listEvents`.
4. Replay the same delivery id and confirm no second event appears.

<Card title="Send and track email" href="/docs/integrations/convex/sending-and-status" description="Read queue state and the webhook-backed delivery result." />
