Send transactional email from Trigger.dev tasks with the Groundmail Node SDK: welcome emails after sign-up, receipts after a payment job, or a drip sequence that waits days between emails. Trigger.dev retries failed tasks, so each send below carries an idempotency key and a retry never emails the same person twice.
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. - A Trigger.dev project using
@trigger.dev/sdk(v4).
Add the API key
Local dev: npx trigger.dev dev reads .env, .env.development and .env.local, so add it there:
GROUNDMAIL_API_KEY=gm_your_key_hereDeployed tasks: in the Trigger.dev dashboard, open Environment Variables, click Add new and set GROUNDMAIL_API_KEY for each environment (staging, prod). Deploys don't read .env.production, so set it in the dashboard.
Install the SDK:
npm install groundmailA task that sends an email
// trigger/welcome-email.ts
import { task } from '@trigger.dev/sdk'
import { Groundmail } from 'groundmail'
const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
export const welcomeEmail = task({
id: 'welcome-email',
retry: { maxAttempts: 5 },
run: async (payload: { email: string; name: string }, { ctx }) => {
const { id } = await groundmail.emails.send({
from: 'App <no-reply@mail.yourdomain.com>',
to: payload.email,
subject: `Welcome, ${payload.name}`,
html: `<p>Hi ${payload.name}, thanks for signing up.</p>`,
// Same run, same key: a retry returns the first send instead of sending again.
idempotencyKey: `welcome-${ctx.run.id}`,
})
return { emailId: id }
},
})Trigger it from your backend. Import the task as a type only, so the task code stays out of your app bundle:
import { tasks } from '@trigger.dev/sdk'
import type { welcomeEmail } from '~/trigger/welcome-email'
await tasks.trigger<typeof welcomeEmail>('welcome-email', {
email: user.email,
name: user.name,
})ctx.run.id stays the same across retries of one run, which is what makes it a safe idempotency key. Groundmail keeps keys for 24 hours and replays the original response (msg_… id and status) for a repeat.
A drip sequence: wait, then send
wait.for pauses the run. On Trigger.dev Cloud, waits longer than 5 seconds don't count toward compute, so waiting days costs nothing.
// trigger/onboarding.ts
import { task, wait } from '@trigger.dev/sdk'
import { Groundmail } from 'groundmail'
import { hasCreatedProject } from '~/lib/users' // your own lookup
const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
const from = 'App <no-reply@mail.yourdomain.com>'
export const onboarding = task({
id: 'onboarding-drip',
run: async (payload: { userId: string; email: string }, { ctx }) => {
await groundmail.emails.send({
from,
to: payload.email,
subject: 'Welcome aboard',
html: '<p>Here is how to get started.</p>',
idempotencyKey: `onboarding-1-${ctx.run.id}`,
})
await wait.for({ days: 3 })
// Decide at send time: skip the nudge if they already did the thing.
if (await hasCreatedProject(payload.userId)) return { skipped: true }
await groundmail.emails.send({
from,
to: payload.email,
subject: 'Need a hand with your first project?',
html: '<p>Reply to this email and we will help.</p>',
idempotencyKey: `onboarding-2-${ctx.run.id}`,
})
},
})wait.for takes seconds, minutes, hours, days, weeks, months or years. Use wait.until({ date }) for a fixed time.
To delay the whole run instead, pass delay when you trigger it: tasks.trigger<typeof welcomeEmail>('welcome-email', payload, { delay: '1h' }).
Trigger.dev delay or Groundmail scheduled_at?
Both work. Pick by where the decision lives:
| You want to | Use |
|---|---|
| Send one known email at a set time | scheduledAt on the send ('2026-11-01T09:00:00Z' or a Date). Groundmail stores it, and you can reschedule or cancel it with groundmail.emails.update / groundmail.emails.cancel. No task needs to wait. |
| Check something before sending (did they upgrade, did they verify) | wait.for in a task, then send. |
Scheduled emails can't carry attachments. See Schedule, reschedule, and cancel.
Don't retry errors that won't fix themselves
The SDK throws GroundmailError with the HTTP status and a stable code. A 5xx or 429 is worth retrying; a 4xx like an unverified From domain will fail every time. Stop those early with AbortTaskRunError:
import { AbortTaskRunError } from '@trigger.dev/sdk'
import { GroundmailError } from 'groundmail'
try {
await groundmail.emails.send({ /* … */ })
} catch (err) {
if (err instanceof GroundmailError && err.status >= 400 && err.status < 500 && err.status !== 429) {
throw new AbortTaskRunError(`${err.code ?? err.status}: ${err.message}`)
}
throw err // let Trigger.dev retry
}Troubleshooting
| Problem | Fix |
|---|---|
GROUNDMAIL_API_KEY is undefined in a deployed run |
Set it under Environment Variables for that environment, then redeploy. .env files only apply to trigger.dev dev. |
401 Unauthorized |
The key was revoked or belongs to another team. 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 an idempotencyKey built from ctx.run.id, not a random value or a timestamp. |
429 from Groundmail |
Your account hit a sending limit. Let the task retry with backoff, and see Sending limits. |
Related: Node.js SDK · Send email API · Inngest · Upstash QStash