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.comworks well. - An API key (
gm_…) from API keys in the dashboard. A scoped key with onlyemails:sendis enough. - From the Upstash console's QStash page:
QSTASH_TOKEN,QSTASH_CURRENT_SIGNING_KEYandQSTASH_NEXT_SIGNING_KEY. Each QStash region has its own token and keys; if yours isn't the default EU region, also setQSTASH_URLto the URL the console shows.
Set them, with GROUNDMAIL_API_KEY, in your app's environment variables:
GROUNDMAIL_API_KEY=gm_your_key_here
QSTASH_TOKEN=…
QSTASH_CURRENT_SIGNING_KEY=…
QSTASH_NEXT_SIGNING_KEY=…npm install groundmail @upstash/qstashThe 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:
// 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:
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:
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:
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:
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