Process webhooks
Verify provider webhook requests in application code and store normalized delivery events in the Convex component.
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
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.
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.
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
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
- Send through a provider test address and capture the stored
providerMessageId. - POST a signed test webhook to the route.
- Query
email.statusandemail.listEvents. - Replay the same delivery id and confirm no second event appears.
Send and track email
Read queue state and the webhook-backed delivery result.
