---
title: "Delivery tracking"
description: "Live delivery status for every sent email — the /email/events webhook, useEmailStatus, and per-event hooks for bounces and complaints."
canonical_url: "https://nuxt-backend.dev/docs/email/delivery-tracking"
---
# Delivery tracking

> Live delivery status for every sent email — the /email/events webhook, useEmailStatus, and per-event hooks for bounces and complaints.

Every email sent through the component is tracked: the provider's delivery events arrive on the **/email/events** webhook mounted by [`registerBackendRoutes`](https://nuxt-backend.dev/docs/ship/production/webhooks), signature-verified with `EMAIL_WEBHOOK_SECRET`, and advance the email's status (`queued` → `sent` → `delivered` / `bounced` / `complained` / `failed`, plus `delivery_delayed`, `opened`, `clicked`). The `useEmailStatus` composable subscribes to it reactively.

## Point the provider at the route

Resend dashboard → Webhooks → add `https://<slug>.convex.site/email/events`, subscribed to the `email.*` events (and the `contact.*` / `domain.*` ones if you use the marketing surface). Store the signing secret:

```bash
echo 'EMAIL_WEBHOOK_SECRET=whsec_...' >> .env.local
npx nuxt-backend env push
```

Until the secret is set the route is mounted but **fail-closed**: every delivery answers `503`, `useEmailStatus` stays at `sent`, and `npx nuxt-backend doctor` reports it (`email-webhook-route`). The secret accepts a comma-separated list (`whsec_new,whsec_old`) for zero-downtime rotation.

## `useEmailStatus()`

```vue
<script setup lang="ts">
import { api } from '#backend/api'

const send = useAction(api.email.send)
const emailId = ref<string>()
const delivery = useEmailStatus({ emailId })

async function notify() {
  emailId.value = (await send({ to: 'delivered@resend.dev', subject: 'Hi', text: 'Hello' })) ?? undefined
}
</script>

<template>
  <button @click="notify">Send</button>
  <p v-if="delivery.isLoading.value">Sending…</p>
  <p v-else-if="delivery.isDelivered.value">Delivered ✓</p>
  <p v-else-if="delivery.isFailed.value">{{ delivery.data.value?.errorMessage ?? delivery.status.value }}</p>
  <p v-else-if="delivery.error.value">{{ delivery.error.value.message }}</p>
  <p v-else>{{ delivery.status.value }}</p>
</template>
```

<field-group>
<field name="data" type="ComputedRef<EmailStatus | null | undefined>">

The full record — `status`, `errorMessage`, `bounced`, `complained`, `failed`, `deliveryDelayed`, `opened`, `clicked`. `null` when unknown, `undefined` while loading.

</field>

<field name="status" type="ComputedRef<string | undefined>">

The status string.

</field>

<field name="isLoading / isDelivered / isFailed">

Loading until the first status arrives; delivered; or bounced / complained / failed.

</field>

<field name="error" type="ComputedRef<BackendError | null>">

Why the status could not be read (`{ code, data, message }`), or `null`. A failed delivery is `isFailed`, not `error`; a failed read never throws.

</field>
</field-group>

Tracking pauses while `emailId` (a value, ref or getter) is `undefined` or empty. The composable reads the auto-provided `api.email` namespace (`getEmailStatus`, re-exported by the scaffolded `email.ts`); pass `{ api }` to point it elsewhere.

<note>

`getEmailStatus` requires a signed-in caller: delivery records carry recipient and error detail, and email ids leak through return values and logs, so the lookup is never anonymous.

</note>

## Reacting to events

Per-event hooks go on `setupEmail`, keyed by the provider's event names — the complete catalog (`email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.opened`, `email.clicked`, `email.failed`, `email.scheduled`, `email.received`, `email.suppressed`, `contact.created` / `updated` / `deleted`, `domain.created` / `updated` / `deleted`). Handlers run **after** the component has verified the signature and recorded the status, so `useEmailStatus` already reflects the event:

```ts [backend/email.ts]
export const email = setupEmail(components, {
  events: {
    'email.bounced': async (ctx, event) => {
      // event.data: { email_id, to, subject, bounce: { type, subType, message }, … }
      await ctx.runMutation(internal.suppressions.add, { email: event.data.to?.[0] })
    },
    'email.complained': async (ctx, event) => { /* alert an admin */ },
    'contact.created': async (ctx, event) => { /* event.data: { id, segment_ids, email, … } */ },
  },
  // A verified event this package's catalog doesn't know (a newer provider) — acknowledged 202 either way.
  onUnknownEvent: async (ctx, { type, payload }) => { /* … */ },
})
```

A handler throw answers `500`, so the provider redelivers — and a redelivery of an already fully-processed delivery id answers `200` without running anything again.

## The delivery log

Every request's outcome (`ok`, `invalid_signature`, `unknown_type`, `handler_error`, `duplicate`, `oversized`, `missing_secret`) lands in the component's delivery log. A delivery whose signature verified gets a row of its own, among the newest 200; a request refused before that, or a duplicate, is counted on one row per kind, so a flood of them can't push a real delivery out. `getWebhookDeliveries` (re-exported by the scaffolded `billing.ts`) serves the log to admins, and DevTools reads it through it; for anyone else it answers `null`.

<page-keys>

- Try it live in the playground: [/playground/platform/email](https://nuxt-backend.dev/playground/platform/email)
- API reference: [/docs/reference/api/use-email-status](https://nuxt-backend.dev/docs/reference/api/use-email-status)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-email-status.ts](https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-email-status.ts)

</page-keys>


## Sitemap

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