Guide/Email Marketing Integration

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:

StreamWhat goes through itUnsubscribe behavior
marketingCampaigns and nurture sequencesBlocked by an unsubscribe
transactionalWhat your application triggers through /api/v1/email/transactional/sendNever blocked by a marketing unsubscribe
systemGrowth'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/identifyPOST /email/contacts
Contact does not existCreatedCreated
tagsAdded to what is thereReplaces the whole set
customFieldsMerged key by keyReplaces the whole record
Use it forApp events: signup, purchase, attributionMirroring 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;
  };
}

Example after a freebie opt-in:

await triggerGrowthWorkflow({
  workflowId: process.env.GROWTH_FREEBIE_WORKFLOW_ID!,
  email: lead.email,
  firstName: lead.name,
  tags: ["freebie"],
});

The call upserts the contact, merges the tags with existing ones (tags are never removed by this endpoint), and enrolls the contact in the workflow. Enrollment is idempotent, so re-submitting the same email is safe. See the Trigger Workflow API reference for status codes and behavior details.

To deliver a downloadable resource from the workflow email, add a Button block in the email step and use Link a file to upload the file (PDF, zip, audio); the button then links to the hosted file automatically.

Signup And Billing Hooks

Add contacts at the moment your app learns a stable user email. Good integration points are:

EventSuggested tagsCustom fields
Newsletter signupnewslettersource, landingPage
Account createduser, trialuserId, plan
Stripe checkout completedcustomer, paidstripeCustomerId, plan
Affiliate-referred signupaffiliate, trialcustomerKey, ref

Example after creating a user:

await upsertGrowthEmailContact({
  email: user.email,
  firstName: user.firstName,
  lastName: user.lastName,
  tags: ["user", "trial"],
  customFields: {
    userId: user.id,
    plan: user.plan,
  },
});

Variables And Segmentation

Email subjects and content blocks can use variables resolved per contact:

Hi @firstName|there
Your current plan is @plan|free
Contact email: @email

The value before | is the contact field or custom field key. The value after | is the fallback when the contact does not have that field.

Use customFields for values you want to segment or personalize against, such as plan, company, role, source, or stripeCustomerId.

API Reference

POST /api/v1/email/contacts

Authentication:

x-api-key: org_api_key

Request body:

FieldTypeRequiredDescription
emailstringYesContact email. Existing contacts are updated by organization and email.
firstNamestringNoContact first name.
lastNamestringNoContact last name.
tagsstring[] or comma stringNoTags used for campaign audiences and dynamic segments.
customFieldsRecord<string, string>NoExtra values used for segmentation and reporting.

Example response:

{
  "contact": {
    "id": "jh7...",
    "email": "[email protected]",
    "firstName": "Lead",
    "lastName": "Example",
    "status": "subscribed",
    "source": "api",
    "customFields": {
      "plan": "pro"
    },
    "tags": ["trial", "newsletter"],
    "createdAt": 1783418783000,
    "updatedAt": 1783418783000
  }
}

Best Practices

Troubleshooting

If requests return 401, confirm the API key is active in Settings > API Keys and that the request sends either x-api-key or Authorization: Bearer <key>.

If contacts do not appear in campaign audiences, confirm their status is subscribed and that tag spelling matches the campaign audience tags.

Affiliate Tracking SetupEmail Marketing Infrastructure