Send transactional email from Inngest functions with the Groundmail Node SDK. An event such as app/user.signed_up starts a function, a step.run sends the email, and step.sleep spaces out a drip sequence over hours or days. Each send carries an idempotency key, so a retried step never emails anyone twice.
The examples use the Inngest TypeScript SDK v4, where triggers go in the function's options (triggers: { event: … }).
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. - An app serving Inngest functions (for example at
/api/inngestin Next.js).
Add the API key
Set GROUNDMAIL_API_KEY wherever your app runs: .env.local for local dev with npx inngest-cli@latest dev, and your host's environment variables in production (Vercel, Netlify, Railway and so on). Inngest calls your app's endpoint, so the key lives with your app, not in Inngest.
npm install groundmail inngestSend an email when an event arrives
// inngest/client.ts
import { Inngest } from 'inngest'
export const inngest = new Inngest({ id: 'my-app' })// inngest/welcome-email.ts
import { Groundmail } from 'groundmail'
import { inngest } from './client'
const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
export const welcomeEmail = inngest.createFunction(
{ id: 'welcome-email', triggers: { event: 'app/user.signed_up' } },
async ({ event, step, runId }) => {
const { id } = await step.run('send-welcome', () =>
groundmail.emails.send({
from: 'App <no-reply@mail.yourdomain.com>',
to: event.data.email,
subject: `Welcome, ${event.data.name}`,
html: `<p>Hi ${event.data.name}, thanks for signing up.</p>`,
// Same run, same key: a retried step returns the first send.
idempotencyKey: `welcome-${runId}`,
}),
)
return { emailId: id }
},
)Register the function with your serve handler, then send the event from your app:
// app/api/inngest/route.ts (Next.js App Router)
import { serve } from 'inngest/next'
import { inngest } from '@/inngest/client'
import { welcomeEmail } from '@/inngest/welcome-email'
export const { GET, POST, PUT } = serve({ client: inngest, functions: [welcomeEmail] })await inngest.send({
name: 'app/user.signed_up',
data: { email: user.email, name: user.name },
})Each step.run retries on its own when it throws, and a step that already succeeded isn't run again on later attempts. Wrapping the send in a step keeps a failure elsewhere in the function from sending a second email.
A drip sequence with step.sleep
// inngest/onboarding.ts
import { Groundmail } from 'groundmail'
import { inngest } from './client'
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 = inngest.createFunction(
{ id: 'onboarding-drip', triggers: { event: 'app/user.signed_up' } },
async ({ event, step, runId }) => {
await step.run('send-welcome', () =>
groundmail.emails.send({
from,
to: event.data.email,
subject: 'Welcome aboard',
html: '<p>Here is how to get started.</p>',
idempotencyKey: `onboarding-1-${runId}`,
}),
)
await step.sleep('wait-3-days', '3 days')
// Decide at send time: skip the nudge if they already did the thing.
const active = await step.run('check-activity', () => hasCreatedProject(event.data.userId))
if (active) return { skipped: true }
await step.run('send-nudge', () =>
groundmail.emails.send({
from,
to: event.data.email,
subject: 'Need a hand with your first project?',
html: '<p>Reply to this email and we will help.</p>',
idempotencyKey: `onboarding-2-${runId}`,
}),
)
},
)step.sleep takes milliseconds or a duration string such as '30m', '3 hours' or '2.5d'. While it sleeps, nothing runs on your server.
Inngest sleep or Groundmail scheduled_at?
| 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. |
| Check something before sending (did they upgrade, did they verify) | step.sleep, then a step that checks and sends. |
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. Retry 5xx and 429; stop on other 4xx errors (an unverified From domain fails every time) with NonRetriableError:
import { NonRetriableError } from 'inngest'
import { GroundmailError } from 'groundmail'
await step.run('send-receipt', async () => {
try {
return await groundmail.emails.send({ /* … */ })
} catch (err) {
if (err instanceof GroundmailError && err.status >= 400 && err.status < 500 && err.status !== 429) {
throw new NonRetriableError(err.message, { cause: err })
}
throw err // let Inngest retry the step
}
})Troubleshooting
| Problem | Fix |
|---|---|
GROUNDMAIL_API_KEY is undefined in a function |
Functions run in your app, so set the variable on your app's host and redeploy. |
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 | Send inside step.run, and build the idempotencyKey from runId or event.id, not a random value. |
429 from Groundmail |
Your account hit a sending limit. Let the step retry, and see Sending limits. |
Related: Node.js SDK · Send email API · Trigger.dev · Upstash QStash