Email Marketing Integration
Add Growth email contacts from Next.js or TanStack Start applications.
Use this guide in the SaaS app where leads, users, customers, or newsletter subscribers are created. The app sends contacts to Growth through your backend so the Growth API key never reaches browser code.
Where Contacts Arrive
Contacts created through the API appear under Email marketing > Contacts. Those contacts can then be used in:
- Campaigns for one-off broadcast sends.
- Segments for tag, engagement, status, and custom-field filters.
- Sequences for automated lifecycle emails.
- Editor for campaign previews, variables, image blocks, and snippets.
- Snippets for reusable email content blocks.
- Settings for provider, webhook, API, and infrastructure details.
Before sending from an application's domain, open Email marketing > Settings in Growth and add an application sender. Choose Resend, SES, Brevo, or SMTP, enter the provider credentials, then add a sending identity — the address emails go out from. The first identity becomes the default sender for everything.
For the full Amazon SES walkthrough (domain verification, DNS records, SNS events), see the "Sending From Your Own Domain" guide. For platform provider and worker setup, see the email marketing infrastructure guide.
Streams: Marketing And Transactional Are Separate
Every email belongs to one of three streams, and the stream decides what an unsubscribe blocks:
| Stream | What goes through it | Unsubscribe behavior |
|---|---|---|
marketing | Campaigns and nurture sequences | Blocked by an unsubscribe |
transactional | What your application triggers through /api/v1/email/transactional/send | Never blocked by a marketing unsubscribe |
system | Growth's own mail (sign-in links, platform alerts) | Always sent by the platform |
Only a hard bounce or a spam complaint blocks every stream, transactional included — there the address is genuinely dead or the recipient reported you.
This matters for your application's error handling: a 409 from the
transactional endpoint now carries a code. contact_bounced and
contact_complained mean a paid-for email was not delivered and deserve an
alert; there is no longer a case where a routine unsubscribe silently stops
transactional delivery.
Streams can also route to different senders — marketing from
[email protected], transactional from [email protected] — under
Email marketing > Settings > Senders.
Create An API Key
Open Settings > API Keys in the Growth organization workspace and create a server key.
GROWTH_API_URL="https://your-growth-app.com"
GROWTH_API_KEY="org_api_key"For local development, use the URL where Growth is running, for example http://localhost:3000.
Pick The Right Endpoint
Two endpoints upsert a contact, and the difference matters.
POST /email/contacts/identify | POST /email/contacts | |
|---|---|---|
| Contact does not exist | Created | Created |
tags | Added to what is there | Replaces the whole set |
customFields | Merged key by key | Replaces the whole record |
| Use it for | App events: signup, purchase, attribution | Mirroring an external list you own entirely |
Use identify for everything an application does in reaction to a user action. It is the only endpoint that several independent call sites can hit without destroying each other's data.
The failure mode is quiet and expensive. Say your freebie flow tagged a lead freebie and stored ref for attribution. Later the user creates an account and your signup hook calls POST /email/contacts with {"tags":["signup"]}. That contact now has exactly one tag, signup, and no ref — the freebie segment silently loses a member and the attribution is gone. Nothing errors.
POST /email/contacts/:email/tags is additive, but it returns 404 when the contact does not exist yet. Signup hooks usually fire before Growth has ever seen the address, so they need identify, not tags.
Create src/lib/growth-email.ts
Create this helper in the app you want to connect. It works in Next.js, TanStack Start, and any server-side TypeScript runtime with fetch.
const apiUrl = process.env.GROWTH_API_URL?.replace(/\/$/, "");
const apiKey = process.env.GROWTH_API_KEY;
type ContactMetadata = Record<string, string | number | boolean | null | undefined>;
export type GrowthEmailContactInput = {
email: string;
firstName?: string | null;
lastName?: string | null;
/** Added to the contact's existing tags; never removes any. */
tags?: string[];
removeTags?: string[];
/** Merged into the contact's stored fields; never replaces the record. */
customFields?: ContactMetadata;
};
const compact = (payload: Record<string, unknown>) =>
Object.fromEntries(
Object.entries(payload).filter(([, value]) => {
if (value === undefined || value === null) return false;
if (Array.isArray(value)) return value.length > 0;
return typeof value !== "string" || value.trim().length > 0;
}),
);
const cleanCustomFields = (fields: ContactMetadata | undefined) => {
if (!fields) return undefined;
const entries = Object.entries(fields).flatMap(([key, value]) => {
if (value === undefined || value === null) return [];
return [[key, String(value)]];
});
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
};
export async function upsertGrowthEmailContact(input: GrowthEmailContactInput) {
if (!apiUrl || !apiKey) {
throw new Error("Missing GROWTH_API_URL or GROWTH_API_KEY");
}
const response = await fetch(`${apiUrl}/api/v1/email/contacts/identify`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify(
compact({
email: input.email,
firstName: input.firstName,
lastName: input.lastName,
tags: input.tags,
removeTags: input.removeTags,
customFields: cleanCustomFields(input.customFields),
}),
),
});
if (!response.ok) {
throw new Error(await response.text());
}
return (await response.json()) as {
contact: {
id: string;
email: string;
status: "subscribed" | "unsubscribed" | "bounced" | "complained";
tags: string[];
};
created: boolean;
addedTags: string[];
removedTags: string[];
/** Non-empty when the 50-tag or 20-field cap was hit. */
skippedTags: string[];
skippedCustomFields: string[];
};
}The call is idempotent: replaying it returns an empty addedTags and changes nothing. skippedTags and skippedCustomFields are the only silent-truncation risk — log them if your app writes many tags per contact.
Next.js App Router
Create a route handler that your forms, signup flow, or webhook handlers can call.
// app/api/growth/email-contact/route.ts
import { upsertGrowthEmailContact } from "@/lib/growth-email";
export async function POST(request: Request) {
const body = (await request.json()) as {
email?: string;
firstName?: string;
lastName?: string;
tags?: string[];
};
if (!body.email) {
return Response.json({ message: "Missing email" }, { status: 400 });
}
const result = await upsertGrowthEmailContact({
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
tags: body.tags ?? ["newsletter"],
customFields: {
source: "nextjs",
},
});
return Response.json(result);
}Call it from a client component after a newsletter form submit:
async function subscribe(email: string) {
const response = await fetch("/api/growth/email-contact", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, tags: ["newsletter"] }),
});
if (!response.ok) {
throw new Error("Subscription failed");
}
}TanStack Start
Create a server route that proxies browser submissions to Growth.
// src/routes/api/growth/email-contact.ts
import { upsertGrowthEmailContact } from "@/lib/growth-email";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/growth/email-contact")({
server: {
handlers: {
POST: async ({ request }) => {
const body = (await request.json()) as {
email?: string;
firstName?: string;
lastName?: string;
tags?: string[];
};
if (!body.email) {
return Response.json({ message: "Missing email" }, { status: 400 });
}
const result = await upsertGrowthEmailContact({
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
tags: body.tags ?? ["newsletter"],
customFields: {
source: "tanstack-start",
},
});
return Response.json(result);
},
},
},
});In a TanStack Form submit handler, call your local route instead of calling Growth directly:
await fetch("/api/growth/email-contact", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: values.email,
firstName: values.firstName,
tags: ["trial", "newsletter"],
}),
});Trigger A Workflow From Your App
Beyond upserting contacts, your backend can directly enroll a contact in a workflow — for example to deliver a freebie or start an onboarding sequence right after an opt-in form.
In Growth, create the workflow under Email marketing > Workflows, set its trigger to API request (the settings panel shows the workflowId and a copyable curl example), add your email steps, and set the workflow to Active.
When to use this instead of identify. Trigger a workflow when you want a specific workflow to run for this contact, right now. If you only want to record what happened and let Growth decide what follows, call identify and give the workflow a Tag added trigger: the tag fires it exactly the same way, and you stop hardcoding workflow IDs in your app's environment.
Before identify existed, the trigger endpoint was the only one that upserted and merged tags, so integrations created "tagging-only" workflows purely as transport. That workaround is no longer needed — and it never fit cleanly, since a workflow requires at least one step.
Then call the trigger endpoint from your server:
// src/lib/growth-workflow.ts
const apiUrl = process.env.GROWTH_API_URL?.replace(/\/$/, "");
const apiKey = process.env.GROWTH_API_KEY;
export async function triggerGrowthWorkflow(input: {
workflowId: string;
email: string;
firstName?: string;
lastName?: string;
tags?: string[];
}) {
if (!apiUrl || !apiKey) {
throw new Error("Missing GROWTH_API_URL or GROWTH_API_KEY");
}
const response = await fetch(`${apiUrl}/api/v1/email/workflows/trigger`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(await response.text());
}
return (await response.json()) as {
contact: { id: string; email: string; status: string; tags: string[] };
created: boolean;
};
}