# Send email with Upstash QStash

[Upstash QStash](https://upstash.com/docs/qstash) delivers an HTTP request to your endpoint later: after a delay, at a set time, or on a cron schedule, with retries. Point it at an endpoint that sends with Groundmail and you get delayed and recurring email from serverless apps (Vercel, Netlify, Cloudflare) without running a queue or a worker.

---

## Prerequisites

- A Groundmail account with a [verified sending domain](https://docs.groundmail.dev/domains). A subdomain such as `mail.yourdomain.com` works well.
- An API key (`gm_…`) from **API keys** in the dashboard. A [scoped key](https://docs.groundmail.dev/api-keys) with only `emails:send` is enough.
- From the Upstash console's QStash page: `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY`. Each QStash region has its own token and keys; if yours isn't the default EU region, also set `QSTASH_URL` to the URL the console shows.

Set them, with `GROUNDMAIL_API_KEY`, in your app's environment variables:

```bash
GROUNDMAIL_API_KEY=gm_your_key_here
QSTASH_TOKEN=…
QSTASH_CURRENT_SIGNING_KEY=…
QSTASH_NEXT_SIGNING_KEY=…
```

```bash
npm install groundmail @upstash/qstash
```

---

## 1. The endpoint that sends

QStash calls this route. It checks the `Upstash-Signature` header, so nobody else can make your app send email, then sends with the SDK. Next.js App Router:

```ts
// app/api/send-email/route.ts
import { verifySignatureAppRouter } from '@upstash/qstash/nextjs'
import { Groundmail, GroundmailError } from 'groundmail'

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)

export const POST = verifySignatureAppRouter(async (req: Request) => {
  const { email, name, key } = await req.json()

  try {
    const { id } = await groundmail.emails.send({
      from: 'App <no-reply@mail.yourdomain.com>',
      to: email,
      subject: 'Your trial ends tomorrow',
      html: `<p>Hi ${name}, your trial ends tomorrow. Pick a plan to keep your data.</p>`,
      idempotencyKey: key, // a QStash retry returns the first send instead of sending again
    })
    return Response.json({ id })
  } catch (err) {
    // A 4xx such as an unverified From domain won't fix itself: tell QStash to stop retrying.
    if (err instanceof GroundmailError && err.status >= 400 && err.status < 500 && err.status !== 429) {
      return new Response(err.message, { status: 489, headers: { 'Upstash-NonRetryable-Error': 'true' } })
    }
    throw err // any non-2xx response makes QStash retry with backoff
  }
})
```

The route must be reachable on the public internet: QStash can't call `localhost`. For local testing, run the QStash local development server or a tunnel.

On other frameworks, verify with `Receiver` from `@upstash/qstash`, and pass it the raw request body (not a re-serialized object), or verification fails.

---

## 2. Send after a delay

Publish a message from anywhere in your app, for example when a trial starts:

```ts
import { Client } from '@upstash/qstash'

const qstash = new Client({ token: process.env.QSTASH_TOKEN! })

await qstash.publishJSON({
  url: 'https://your-app.com/api/send-email',
  body: { email: user.email, name: user.name, key: `trial-ending-${user.id}` },
  delay: 13 * 24 * 60 * 60, // seconds: 13 days from now
})
```

Use `notBefore` (a Unix timestamp in seconds) instead of `delay` for a fixed time. The longest delay depends on your Upstash plan: 7 days on the free plan, up to a year on pay-as-you-go.

The same request with cURL:

```bash
curl -X POST "${QSTASH_URL:-https://qstash.upstash.io}/v2/publish/https://your-app.com/api/send-email" \
  -H "Authorization: Bearer $QSTASH_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Upstash-Delay: 13d" \
  -d '{"email":"maya@example.com","name":"Maya","key":"trial-ending-42"}'
```

`Upstash-Delay` takes values such as `50s`, `10h`, `3d` or `1d10h30m`.

---

## 3. Send on a schedule

For a recurring email, such as a daily summary, create a schedule that calls an endpoint which builds and sends it:

```ts
await qstash.schedules.create({
  destination: 'https://your-app.com/api/daily-summary',
  cron: 'CRON_TZ=America/New_York 0 9 * * *', // 09:00 New York time, every day
})
```

Cron expressions run in UTC unless you add a `CRON_TZ=` prefix. In the summary endpoint, build the idempotency key from the date (`summary-${userId}-${today}`), so a retried delivery doesn't send the same day's summary twice.

---

## No endpoint of your own

QStash can call the Groundmail API directly. Headers prefixed with `Upstash-Forward-` reach the destination with the prefix removed, so this sends an email in three days with no code on your side:

```bash
curl -X POST "${QSTASH_URL:-https://qstash.upstash.io}/v2/publish/https://api.groundmail.dev/emails" \
  -H "Authorization: Bearer $QSTASH_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Upstash-Delay: 3d" \
  -H "Upstash-Forward-Authorization: Bearer $GROUNDMAIL_API_KEY" \
  -H "Upstash-Forward-Idempotency-Key: checkin-42" \
  -d '{"from":"App <no-reply@mail.yourdomain.com>","to":"maya@example.com","subject":"How is it going?","html":"<p>Any questions so far?</p>"}'
```

QStash stores the forwarded headers with the message, so use a key scoped to `emails:send` only. For a single email at a known time you don't need QStash at all: add `scheduled_at` to the send and Groundmail holds it (see [Schedule, reschedule, and cancel](https://docs.groundmail.dev/emails#schedule-reschedule-and-cancel)). QStash earns its place when your endpoint decides at send time, or for cron schedules.

---

## Troubleshooting

| Problem | Fix |
|---------|-----|
| QStash shows `401` from your endpoint | The signing keys in your app don't match the QStash region or project that sent the message. Copy them again from the Upstash console. |
| Messages never arrive at a local URL | QStash only calls public URLs. Use the local development server or a tunnel. |
| `401 Unauthorized` from Groundmail | The `GROUNDMAIL_API_KEY` on your app (or in the forwarded header) was revoked. Create a new one under **API keys**. |
| `domain is not verified` | Send from an address on a domain you verified under **Domains**. |
| A retry sent a second email | Pass a stable `idempotencyKey` in the message body. Groundmail keeps keys for 24 hours. |
| The delay was rejected | It is longer than your Upstash plan allows. Use `scheduled_at` on the Groundmail send for a far-off fixed time. |

Related: [Node.js SDK](https://docs.groundmail.dev/frameworks/node) · [Send email API](https://docs.groundmail.dev/emails) · [Trigger.dev](https://docs.groundmail.dev/integrations/trigger-dev) · [Inngest](https://docs.groundmail.dev/integrations/inngest)
