Skip to content

Send lifecycle emails ​

funnelsgrove email sends published templates to your funnel users: one-off transactional messages from your backend, and lifecycle sequences triggered by funnel events. This guide walks through the setup with the Node.js client @funnelsgrove/emails and the fgrove email CLI. The HTTP endpoints behind them are in the reference: templates, sequences and deliveries.

Authenticate with a project secret key or an API key holding the email scopes, from a backend only. Follow the email sending policy before sending to your audience.

Before you send ​

  1. Authenticate your sending domain. Open Emails → Domains, add your domain and check DKIM, MAIL FROM/SPF and DMARC. SES verification alone does not check DMARC. Follow Set up your email domain for the DNS records and the move from monitoring to enforcement. Lifecycle sequences cannot be enabled until the domain has a valid DMARC policy; subdomains can inherit the policy of their parent domain.
  2. Keep lifecycle sending on. The project-level Lifecycle email switch on the Emails page pauses or resumes lifecycle sending. While paused, funnelsgrove keeps pending trigger eligibility, enrollments and queued deliveries, but creates no new enrollments and makes no provider send attempts.
  3. Create the client.
ts
import { createEmailsClient } from '@funnelsgrove/emails';

const emails = createEmailsClient({
  privateToken: process.env.FUNNELSGROVE_PRIVATE_TOKEN!,
});

privateToken takes either credential. With an API key, also pass the project it acts on; the client adds it as the projectId query parameter and throws without it:

ts
const emails = createEmailsClient({
  privateToken: process.env.FUNNELSGROVE_API_KEY!, // fg_key_…
  projectId: process.env.FUNNELSGROVE_PROJECT_ID!,
});

Author a template ​

A template has a draft and published versions; sending uses the published version by slug. Create it with the client, or edit template files locally with the CLI.

ts
const template = await emails.createTemplate({
  slug: 'order-confirmation',
  name: 'Order confirmation',
  draft: {
    subject: 'Order {{ORDER_ID}}',
    previewText: 'Your order is confirmed.',
    html: '<p>Order {{ORDER_ID}}</p><p>Total: {{PRICE}}</p>',
    text: 'Order {{ORDER_ID}}\nTotal: {{PRICE}}',
    variables: { ORDER_ID: 'string', PRICE: 'number' },
    variableDescriptions: { ORDER_ID: 'Order reference shown on the receipt' },
  },
});

await emails.updateTemplate(template.id, {
  draft: { subject: 'Order {{ORDER_ID}} confirmed' },
});

await emails.validateTemplate(template.id);
await emails.publishTemplate(template.id);
bash
fgrove email pull --workspace "$WORKSPACE" --project "$PROJECT"
# Edit emails/templates/order-confirmation/ locally.
fgrove email validate
fgrove email push --workspace "$WORKSPACE" --project "$PROJECT"
fgrove email template publish order-confirmation --workspace "$WORKSPACE" --project "$PROJECT"

Templates must pass validation before publishing.

Start from the template library ​

funnelsgrove ships ready-made subscription templates: subscription-confirmation, subscription-receipt, subscription-renewal and subscription-expiring. Copy one into the project from Emails → Templates → Library or with the CLI, review the draft, then publish it:

bash
fgrove email library list --workspace "$WORKSPACE" --project "$PROJECT"
fgrove email library add subscription-receipt \
  --app-name "Lumen" \
  --manage-url https://lumen.example.com/manage-subscription \
  --icon-url https://mail.lumen.example.com/icon.png \
  --workspace "$WORKSPACE" --project "$PROJECT"
fgrove email template publish subscription-receipt --workspace "$WORKSPACE" --project "$PROJECT"

The app name, icon and links are written into the copy as fixed content. Support email, Terms and Privacy default to the project settings; pass --support-email, --terms-url or --privacy-url to override them. Without --icon-url the email shows a generic app icon; use a square PNG of at least 176×176 px, ideally hosted on the domain you send email from.

Each copy declares string variables for per-send data. fgrove email library list prints every variable with its description. Send dates and prices already formatted for the reader, for example "14 December 2026" and "€69.99/year".

Send a transactional email ​

Send a published template to one recipient. The call returns a queued delivery receipt; read the delivery later for its final status.

ts
const receipt = await emails.send({
  template: 'login-code',
  to: 'delivered@example.com',
  variables: { code: '481953', expiresInMinutes: 10 },
  from: { email: 'security@mail.example.com', name: 'Example Security' },
  replyTo: 'support@example.com',
});

const status = await emails.get(receipt.id);
console.log(status.status);
  • from is optional. Its domain must be a verified sending domain of the project; any local part works. Without name, the domain's sender display name is used. Without from, a verified default sending domain must be active.
  • replyTo accepts one address and may use an external domain. Arbitrary headers and several reply-to addresses are rejected.
  • Pass idempotencyKey when retrying the same logical send; the client generates one otherwise.

Transactional sends can include up to ten CC recipients and one PDF attachment up to 5 MiB. CC recipients pass the same suppression checks and recipient velocity limits as the primary recipient. The client encodes attachment bytes:

ts
import { readFile } from 'node:fs/promises';

await emails.send({
  template: 'manual-invoice',
  to: 'billing@example.com',
  cc: ['finance@example.com'],
  variables: { invoiceNumber: 'MV0002' },
  attachments: [{
    filename: 'MV0002.pdf',
    contentType: 'application/pdf',
    content: await readFile('MV0002.pdf'),
  }],
});

The same send from the CLI waits for a terminal delivery status by default; use --no-wait to return as soon as the email is queued and --idempotency-key when retrying:

bash
export FUNNELSGROVE_PRIVATE_TOKEN="fg_private_..."

fgrove email send \
  --template login-code \
  --to delivered@example.com \
  --variables '{"code":"481953","expiresInMinutes":10}'

FUNNELSGROVE_PRIVATE_TOKEN (or --private-token) accepts a project secret key or an API key. With an API key, also set FUNNELSGROVE_PROJECT_ID or pass --project-id; fgrove email send, update-variables and sequence cancel fail without it.

Build a lifecycle sequence ​

A sequence sends a series of published templates after a trigger event (email_captured, purchase_completed, registration_completed or payment_upcoming) and stops when an exit event happens. An empty funnelIds means every funnel in the project; list funnel IDs to scope it.

ts
const sequence = await emails.createSequence({
  slug: 'lead-follow-up',
  name: 'Lead follow-up',
  draft: {
    triggerEventType: 'email_captured',
    funnelIds: [], // all funnels; use ['funnel-uuid'] to scope it
    steps: [
      {
        key: 'welcome',
        template: 'welcome',
        variables: { firstName: 'first_name' },
        delay: { value: 0, unit: 'hours' },
      },
      {
        key: 'reminder',
        template: 'reminder',
        variables: { firstName: 'first_name' },
        delay: { value: 2, unit: 'days' },
      },
    ],
    exitEventTypes: ['purchase_completed'],
    lifecycleConsentRequired: true,
  },
});

await emails.addSequenceStep(sequence.id, {
  key: 'last-call',
  template: 'last-call',
  variables: { firstName: 'first_name' },
  delay: { value: 48, unit: 'hours' },
});

await emails.validateSequence(sequence.id);
await emails.publishSequence(sequence.id);
await emails.enableSequence(sequence.id);

// Stops new enrollments and blocks queued steps at their pre-send check.
await emails.disableSequence(sequence.id);

Each step references a published template; the draft pins the current published version when it is created or updated. The step's variables bind each declared template variable to a lifecycle value. The first step's delay counts from the trigger, later steps from the preceding send; 0 days + 2 hours sends two hours after the trigger, 1 day + 2 hours waits 26 hours. The maximum delay is 90 days.

lifecycleConsentRequired defaults to true. Set it to false only when you have another valid basis for the messages. The setting is pinned in the published version and does not bypass unsubscribe, hard-bounce, complaint, manual or provider suppression.

Activation is explicit: enabling needs a published version and a sending domain with a valid DMARC policy. Disabling does not rewrite existing enrollment rows, but no later step passes the pre-send status check while the sequence is disabled.

The same workflow with the CLI, choosing one --funnel-id or --all-funnels explicitly:

bash
fgrove email sequence create welcome-series \
  --name "Welcome series" \
  --template welcome \
  --key welcome \
  --funnel-id "$FUNNEL_ID" \
  --delay-hours 0

fgrove email sequence add-step welcome-series \
  --template reminder \
  --key reminder \
  --delay-days 2

fgrove email validate
fgrove email push --workspace "$WORKSPACE" --project "$PROJECT"
fgrove email sequence publish welcome-series --workspace "$WORKSPACE" --project "$PROJECT"
fgrove email sequence enable welcome-series --workspace "$WORKSPACE" --project "$PROJECT"

Edit emails/sequences/welcome-series.json to replace the ordered step list or change its funnelIds, then push and publish again. fgrove email sequence disable welcome-series stops new enrollments.

Confirm a subscription purchase ​

Use purchase_completed and a zero-delay first step with a published confirmation template. Declaring all five variables (customerName, planName, billingTerms, startDate, nextPaymentDate) as strings opts that template into the system confirmation contract. Leave these step values empty: publication uses validation samples, and real delivery uses the immutable verified purchase snapshot. Authored demo values and personalization cannot replace the system fields. The optional reserved funnelUserId continues to supply the customer's management link.

Partial signatures remain ordinary templates with authored values. Missing or unsupported billing evidence blocks the confirmation template without blocking ordinary purchase follow-ups. Snapshots currently support Stripe subscription purchases with one fixed recurring price and supported introductory terms. Actual paid amounts are distinct from plan prices; future taxes or discounts may change charges.

Later full-signature steps reuse the original purchase snapshot, including after ordinary intervening steps, and need no authored billing values. If that original event is missing, has a different scope or lacks a valid snapshot, the confirmation step is not admitted for sending.

A free trial produces its purchase confirmation only after the first successful positive payment, with the original subscription acceptance date. No-payment trial signup and recurring renewal do not produce a new purchase confirmation. Existing sender, scope, suppression, consent, unsubscribe and once-only enrollment gates apply.

To send a one-hour purchase follow-up only while the user has not logged in, use purchase_completed, delay: { value: 1, unit: 'hours' } and exitEventTypes: ['registration_completed']. The console labels that exit Logged in / registered.

Remind before a renewal ​

payment_upcoming supports live Stripe subscriptions only; Solidgate is deferred. Author exactly one published-template step, exitEventTypes: [] and no personalization. The step's delay is the lead time before the charge, from one hour to 45 days. The system supplies customerName, planName, renewalDate, renewalPrice and chargeAt; ordinary template variables may still be authored. Existing consent, suppression, unsubscribe and sender checks apply.

ts
const reminder = await emails.createSequence({
  slug: 'subscription-renewal',
  name: 'Subscription renewal',
  draft: {
    triggerEventType: 'payment_upcoming',
    funnelIds: [],
    steps: [{
      key: 'renewal',
      template: 'subscription-renewal',
      delay: { value: 1, unit: 'days' },
    }],
    exitEventTypes: [],
  },
});
await emails.validateSequence(reminder.id);
await emails.publishSequence(reminder.id);
await emails.enableSequence(reminder.id);
bash
fgrove email sequence create subscription-renewal --name 'Subscription renewal' \
  --template subscription-renewal --key renewal --trigger payment_upcoming \
  --all-funnels --delay-days 1

Personalize a step before it sends ​

Set personalizationEnabled: true on a step to receive the email.personalization_requested webhook 20 minutes before its due time. Its data.stepNumber is the one-based position in the pinned sequence version, including direct steps; use it to vary recommendations across messages. A personalized first email admitted near or after its original due time reserves 20 minutes after admission for this callback.

Update the variables after the webhook and before the delivery deadline:

ts
const personalized = await emails.updateVariables(event.data.deliveryId, {
  firstName: customer.firstName,
  recommendation: customer.recommendation,
});

console.log(personalized.dueAt);

Only declared template variables with valid types are accepted. The request is rejected outside the personalization window or after sending has started; see Update variables.

Cancel a user's enrollment ​

When a user no longer needs a sequence, cancel their active enrollment. The operation is idempotent: the first call reports the number cancelled, a retry after the enrollment is terminal returns cancelled: 0.

ts
const result = await emails.cancelSequenceEnrollment(sequence.id, {
  funnelEndUserId: '00000000-0000-4000-8000-000000000000',
  reason: 'customer_request',
});

console.log(result.cancelled);
bash
export FUNNELSGROVE_PRIVATE_TOKEN="fg_private_..."

fgrove email sequence cancel lead-follow-up \
  --user-id "$FUNNEL_END_USER_ID" \
  --reason customer_request

The CLI uses the sequence slug stored in the local emails/sequences directory and verifies that its ID still belongs to that slug before sending the request. Cancelling prevents later lifecycle sends, including queued deliveries that have not passed their pre-send check; it cannot recall an email the provider has already started sending.

Measure results ​

The Emails tab shows delivery activity and the project's hourly sending limit; the question-mark control opens the other limits and a sending-report request. The Performance tab shows delivered users, Return rate, Email purchase rate, goal reached, unsubscribe rate, hard bounce rate and complaint rate for lifecycle emails sent in the selected period. Expand Performance by email & calculation details for a breakdown by published sequence version and step. Transactional and test emails stay in the activity list but do not enter these metrics.

  • funnelsgrove adds a signed email_return parameter to eligible funnel links; the destination host stays unchanged. Return rate is unique returned funnel users divided by unique users with provider-confirmed delivery. Email purchase rate is unique funnel users who purchase in the same funnel within seven days after a validated return, divided by the same denominator.
  • Goals are the sequence's exit events in the same funnel within seven days after sending. Goals and unsubscribe requests are attributed to the last eligible email, including emails after the selected period, so recent results can still change. A user can appear in several step rows; counts are not additive.
  • Unsubscribe measures requests, attributed to the most recent lifecycle email to that address within seven days, not current subscription status. Deliveries, hard bounces and complaints use provider feedback. Sent means accepted by the provider, not inbox placement. Opens are not tracked.
  • Reports reuse stored daily results. Recent days refresh after eight hours and older days after 24 hours; the first load can take longer. Unique users are deduplicated across days.

Build. Launch. Grow.