SDKs and frameworks/Background jobs/Upstash QStash

Markdown

Send email with Upstash QStash

Updated Oct 24, 2026 · 3 min read

Upstash 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. A subdomain such as mail.yourdomain.com works well.
  • An API key (gm_…) from API keys in the dashboard. A scoped key 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:

Terminal
GROUNDMAIL_API_KEY=gm_your_key_here
QSTASH_TOKEN=…
QSTASH_CURRENT_SIGNING_KEY=…
QSTASH_NEXT_SIGNING_KEY=…
Terminal
npm install groundmail @upstash/qstash

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:

TypeScript
// 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.


Send after a delay

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

TypeScript
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:

Terminal
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.


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:

TypeScript
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:

Terminal
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). 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 · Send email API · Trigger.dev · Inngest

Was this page helpful?