← All posts
·14 min read

Claude Code with SendGrid: Transactional Email at Scale

Claude CodeSendGridEmailAPI
Claude Code with SendGrid: Transactional email at scale

Why SendGrid without CLAUDE.md leaks deliverability

SendGrid is the workhorse transactional email service for backend systems that need to send tens of millions of messages a month. It handles dedicated IPs, sender authentication, deliverability monitoring, and suppression list management at scales smaller providers cannot match. The Node SDK is straightforward, the v3 Mail Send API is well documented, and a basic send is six lines of code. The problem is that the six lines that work in development do not survive at scale, and Claude Code does not know which lines protect deliverability and which are convenience layers.

The most common Claude defaults that hurt SendGrid integrations: hardcoding the API key, ignoring the suppression groups parameter on marketing-adjacent transactional sends, omitting the text: plain text version, using a single personalizations block when batching to multiple recipients (which leaks recipient addresses across the To header), skipping event webhook signature validation, and not handling the asynchronous nature of dynamic template version updates. On top of those, Claude often confuses the v2 (deprecated) and v3 API shapes, generating field names like subject at the top level when the v3 schema requires subject inside personalizations for templated sends.

This guide covers the CLAUDE.md configuration that locks Claude Code into SendGrid's correct model: the v3 Mail Send shape, mandatory suppression group assignment, batched personalizations that respect recipient privacy, event webhook signature verification, and the dynamic template flow that survives a designer iterating on the template after launch. If you are evaluating providers, Claude Code with Resend covers the smaller, developer-first alternative. For payment confirmation flows that trigger SendGrid sends, Claude Code with Stripe shows the webhook handler pattern.

The SendGrid CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a SendGrid integration it needs to declare: the SDK version, the API key environment variable, the v3 Mail Send shape, the suppression group policy, the dynamic template versioning rule, and the hard rules that block the failure modes Claude generates by default.

# SendGrid email rules

## Stack
- @sendgrid/mail ^8.x, TypeScript 5.x strict
- Node.js 20.x (or Next.js 14.x route handlers)
- SENDGRID_API_KEY in .env.local (full access token, never restrict in code)
- SENDGRID_VERIFIED_SENDER for the from address
- SENDGRID_WEBHOOK_PUBLIC_KEY for event webhook signature verification

## Project structure
- src/lib/sendgrid.ts          , SendGrid client setup
- src/emails/                  , dynamic template definitions (template-id mapping)
- src/lib/send-email.ts        , shared send wrapper
- src/app/api/webhooks/sendgrid/ , event webhook handler

## Client setup
- ALWAYS call sgMail.setApiKey() once at module load
- src/lib/sendgrid.ts content:
  import sgMail from '@sendgrid/mail';
  if (!process.env.SENDGRID_API_KEY) throw new Error('SENDGRID_API_KEY missing');
  sgMail.setApiKey(process.env.SENDGRID_API_KEY);
  export default sgMail;
- NEVER call setApiKey() inside route handlers

## v3 Mail Send shape (MANDATORY)
For raw HTML sends, the shape is:
{
  to: string | string[],
  from: { email: string, name: string },
  subject: string,
  html: string,
  text: string,
  asm: { groupId: number, groupsToDisplay: number[] },   // unsubscribe group
  trackingSettings: { clickTracking: { enable: true } },
  customArgs: { user_id?: string, env?: string },         // for event webhooks
  categories: ['transactional', 'app-name'],              // for analytics
}

For dynamic templates, subject moves into personalizations and you set templateId:
{
  to: string,
  from: { email: string, name: string },
  templateId: 'd-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  dynamicTemplateData: { ... },
  asm: { groupId: number, groupsToDisplay: number[] },
  customArgs: { user_id?: string },
  categories: ['transactional'],
}

## Hard rules
- NEVER hardcode SENDGRID_API_KEY in source
- NEVER omit the text field on HTML sends
- NEVER batch multiple recipients in a single to: array, use personalizations
- NEVER set subject at the top level when using templateId, it is ignored
- NEVER skip asm (suppression group) on customer-facing sends, regulatory requirement
- NEVER skip event webhook signature verification
- ALWAYS set categories for analytics segmentation
- ALWAYS set customArgs.user_id for cross-referencing events to your DB

Four rules here matter most.

The personalizations rule for batched sends prevents recipient address leakage. If you pass an array to to:, every recipient sees every other recipient's address in the To header. SendGrid's v3 API supports up to 1000 personalizations blocks per request, each with its own to, subject, and template data. The correct batch pattern is personalizations: [{ to: 'user1@x.com' }, { to: 'user2@y.com' }]. Claude defaults to the array-in-to shape because it is simpler and looks correct, but it is a privacy bug.

The suppression group rule is regulatory. CAN-SPAM (US), CASL (Canada), and GDPR (EU) all require honoring opt-outs. SendGrid models opt-outs through Suppression Groups (also called Unsubscribe Groups). The asm field on every send must reference a group ID and list which groups appear on the recipient's preference page. Claude omits asm by default because the v3 schema does not require it. Including it in CLAUDE.md makes Claude wire it through every send wrapper.

The dynamic template subject rule prevents a silent failure. When templateId is set, the subject from the top level is ignored. The subject must come from either the template itself or from personalizations[].subject. Claude often generates both, expecting the top-level subject to override the template, and developers spend an hour debugging why their custom subject is not appearing.

The event webhook signature rule is a security boundary. SendGrid signs every event webhook with an Ed25519 key. Skipping verification means any third party can POST fake delivery, bounce, or open events to your endpoint and corrupt your analytics or user state.

Install and API key setup

Install the SendGrid Node SDK:

npm i @sendgrid/mail @sendgrid/client

Add the API key to your environment:

# .env.local
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyy
SENDGRID_VERIFIED_SENDER=hello@claudify.tech
SENDGRID_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIK...

Generate the API key in the SendGrid dashboard under Settings -> API Keys with Full Access (or at minimum Mail Send + Marketing Read scopes if you are using categories or stats). Save the key once at creation, SendGrid does not display it again.

The singleton setup:

// src/lib/sendgrid.ts
import sgMail from '@sendgrid/mail';

if (!process.env.SENDGRID_API_KEY) {
  throw new Error('SENDGRID_API_KEY is not set');
}

sgMail.setApiKey(process.env.SENDGRID_API_KEY);

export const VERIFIED_SENDER = {
  email: process.env.SENDGRID_VERIFIED_SENDER ?? 'hello@claudify.tech',
  name: 'Claudify',
};

export default sgMail;

setApiKey mutates the shared SendGrid client. Calling it once at module load is correct. Claude often regenerates the setApiKey call inside route handlers, which works but creates a misleading impression that the call has request-scoped effects. The CLAUDE.md singleton rule prevents this drift.

The v3 Mail Send pattern

A typed wrapper around the v3 Mail Send for raw HTML emails:

// src/lib/send-email.ts
import sgMail, { VERIFIED_SENDER } from '@/lib/sendgrid';

interface SendEmailOptions {
  to: string;
  subject: string;
  html: string;
  text: string;
  unsubscribeGroupId: number;
  userId?: string;
  categories?: string[];
}

export async function sendEmail(options: SendEmailOptions) {
  if (!options.text) {
    throw new Error('text field is required for deliverability');
  }

  try {
    const [response] = await sgMail.send({
      to: options.to,
      from: VERIFIED_SENDER,
      subject: options.subject,
      html: options.html,
      text: options.text,
      asm: {
        groupId: options.unsubscribeGroupId,
        groupsToDisplay: [options.unsubscribeGroupId],
      },
      customArgs: {
        user_id: options.userId ?? '',
        env: process.env.NODE_ENV ?? 'development',
      },
      categories: options.categories ?? ['transactional'],
      trackingSettings: {
        clickTracking: { enable: true, enableText: false },
        openTracking: { enable: true },
      },
    });

    return {
      statusCode: response.statusCode,
      messageId: response.headers['x-message-id'] as string,
    };
  } catch (err) {
    const error = err as { response?: { body?: unknown }; message?: string };
    console.error('[SendGrid] Send failed:', {
      message: error.message,
      body: error.response?.body,
    });
    throw err;
  }
}

Calling this from a Next.js route handler:

// src/app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendEmail } from '@/lib/send-email';

const UNSUBSCRIBE_GROUP_TRANSACTIONAL = 12345;

export async function POST(req: NextRequest) {
  const { name, email, message } = await req.json();

  await sendEmail({
    to: 'support@claudify.tech',
    subject: `Contact form: ${name}`,
    html: `<p><strong>${name}</strong> (${email}) says:</p><p>${message}</p>`,
    text: `${name} (${email}) says:\n\n${message}`,
    unsubscribeGroupId: UNSUBSCRIBE_GROUP_TRANSACTIONAL,
    categories: ['contact-form'],
  });

  return NextResponse.json({ ok: true });
}

Three things this wrapper does that Claude omits without CLAUDE.md guidance. First, it throws if text is missing, surfacing the deliverability issue at send time rather than waiting for spam folder placement to reveal it. Second, it always sets the asm unsubscribe group, which is the compliance requirement that gives SendGrid the data to add the One-Click Unsubscribe header to every send. Third, it captures the x-message-id response header, which is the SendGrid internal ID you use to correlate this send with later event webhook deliveries.

Dynamic templates

SendGrid Dynamic Templates are server-side Handlebars templates hosted in the SendGrid dashboard. They support versioning (drafts, active versions), substitution variables, conditionals, and loops. Sending through a template moves all the layout, branding, and copy out of your code.

The template setup workflow:

  1. In the SendGrid dashboard, navigate to Email API -> Dynamic Templates
  2. Create a new template, give it a descriptive name (e.g., welcome-email)
  3. Add a version, design the HTML in the visual editor or upload via API
  4. Activate the version (only active versions are sent)
  5. Copy the template ID (starts with d-)

A typed sender that calls a dynamic template:

// src/lib/send-template.ts
import sgMail, { VERIFIED_SENDER } from '@/lib/sendgrid';

interface SendTemplateOptions<T extends Record<string, unknown>> {
  to: string;
  templateId: string;
  dynamicTemplateData: T;
  unsubscribeGroupId: number;
  userId?: string;
  categories?: string[];
}

export async function sendTemplate<T extends Record<string, unknown>>(
  options: SendTemplateOptions<T>,
) {
  const [response] = await sgMail.send({
    to: options.to,
    from: VERIFIED_SENDER,
    templateId: options.templateId,
    dynamicTemplateData: options.dynamicTemplateData,
    asm: {
      groupId: options.unsubscribeGroupId,
      groupsToDisplay: [options.unsubscribeGroupId],
    },
    customArgs: {
      user_id: options.userId ?? '',
      env: process.env.NODE_ENV ?? 'development',
    },
    categories: options.categories ?? ['transactional'],
  });

  return {
    statusCode: response.statusCode,
    messageId: response.headers['x-message-id'] as string,
  };
}

A typed registry of template IDs keeps Claude from copy-pasting hex strings into every send site:

// src/emails/templates.ts
export const SENDGRID_TEMPLATES = {
  welcome:           'd-1111111111111111111111111111111111',
  passwordReset:     'd-2222222222222222222222222222222222',
  orderConfirmation: 'd-3333333333333333333333333333333333',
  invoiceReady:      'd-4444444444444444444444444444444444',
} as const;

export const SENDGRID_GROUPS = {
  transactional: 12345,
  marketing:     67890,
  productUpdates: 11111,
} as const;

Then a domain-specific helper:

// src/lib/send-welcome.ts
import { sendTemplate } from '@/lib/send-template';
import { SENDGRID_TEMPLATES, SENDGRID_GROUPS } from '@/emails/templates';

export async function sendWelcomeEmail(toEmail: string, userName: string, userId: string) {
  return sendTemplate({
    to: toEmail,
    templateId: SENDGRID_TEMPLATES.welcome,
    dynamicTemplateData: {
      user_name: userName,
      login_url: 'https://claudify.tech/login',
      current_year: new Date().getFullYear(),
    },
    unsubscribeGroupId: SENDGRID_GROUPS.transactional,
    userId,
    categories: ['welcome', 'onboarding'],
  });
}

Add a dynamic template section to CLAUDE.md:

## Dynamic templates

- Template IDs live in src/emails/templates.ts as a typed const
- NEVER set subject at the top level when templateId is set, it is silently ignored
- NEVER pass html or text alongside templateId, they are silently ignored
- dynamicTemplateData fields map 1-to-1 to Handlebars variables in the template
- Always check the active version in the SendGrid dashboard before debugging missing variables
- Use customArgs.user_id to correlate sends to your DB through event webhooks

The "subject and html ignored when templateId is set" behavior is undocumented in some SDK versions and catches developers debugging why a custom subject is not appearing. The template defines the subject. If you need a per-send subject, configure the template subject as a Handlebars variable ({{subject}}) and pass it through dynamicTemplateData.

Batching with personalizations

For broadcasts or per-recipient customized sends, the personalizations array sends one API call to up to 1000 recipients without leaking addresses. Each personalizations block defines one recipient's full context.

// src/lib/send-batch.ts
import sgMail, { VERIFIED_SENDER } from '@/lib/sendgrid';
import { SENDGRID_TEMPLATES, SENDGRID_GROUPS } from '@/emails/templates';

interface BatchRecipient {
  email: string;
  userName: string;
  userId: string;
}

export async function sendBatchWelcome(recipients: BatchRecipient[]) {
  if (recipients.length === 0) return;
  if (recipients.length > 1000) {
    throw new Error('Batch size exceeds SendGrid limit of 1000 personalizations');
  }

  const [response] = await sgMail.send({
    from: VERIFIED_SENDER,
    templateId: SENDGRID_TEMPLATES.welcome,
    asm: {
      groupId: SENDGRID_GROUPS.transactional,
      groupsToDisplay: [SENDGRID_GROUPS.transactional],
    },
    categories: ['welcome', 'batch'],
    personalizations: recipients.map(r => ({
      to: r.email,
      dynamicTemplateData: {
        user_name: r.userName,
        login_url: 'https://claudify.tech/login',
      },
      customArgs: { user_id: r.userId },
    })),
  });

  return {
    statusCode: response.statusCode,
    batchSize: recipients.length,
  };
}

Add a batching section to CLAUDE.md:

## Batching

- Maximum 1000 personalizations per send
- Each personalizations block is a separate recipient with its own to, data, and customArgs
- For lists over 1000: chunk into 1000-item arrays, pause 1 second between chunks
- NEVER use to: ['user1@x.com', 'user2@y.com'] for batching, it leaks addresses
- Use personalizations: [{ to: 'user1@x.com' }, { to: 'user2@y.com' }] instead
- customArgs in personalizations override customArgs at the top level
- dynamicTemplateData in personalizations is per-recipient

Get Claudify. The bundled SendGrid CLAUDE.md ships pre-configured with the personalizations pattern, the suppression group setup, and a typed template registry helper.

Event webhooks with signature verification

SendGrid sends event webhooks for processed, delivered, open, click, bounce, dropped, deferred, spamreport, and unsubscribe events. Subscribing to these and verifying the Ed25519 signature is how you correlate sends with delivery outcomes and update user state when bounces or unsubscribes occur.

// src/app/api/webhooks/sendgrid/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { EventWebhook } from '@sendgrid/eventwebhook';

const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY ?? '';

interface SendGridEvent {
  email: string;
  event: 'processed' | 'delivered' | 'open' | 'click' | 'bounce' | 'dropped' | 'deferred' | 'spamreport' | 'unsubscribe';
  sg_message_id: string;
  timestamp: number;
  user_id?: string;
  reason?: string;
  status?: string;
}

export async function POST(req: NextRequest) {
  const signature = req.headers.get('x-twilio-email-event-webhook-signature');
  const timestamp = req.headers.get('x-twilio-email-event-webhook-timestamp');
  const payload = await req.text();

  if (!signature || !timestamp) {
    return new NextResponse('Missing signature headers', { status: 403 });
  }

  const eventWebhook = new EventWebhook();
  const ecdsaPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
  const isValid = eventWebhook.verifySignature(ecdsaPublicKey, payload, signature, timestamp);

  if (!isValid) {
    return new NextResponse('Invalid signature', { status: 403 });
  }

  const events = JSON.parse(payload) as SendGridEvent[];

  for (const event of events) {
    switch (event.event) {
      case 'bounce':
      case 'dropped':
        await markAddressUndeliverable(event.email, event.reason);
        break;
      case 'spamreport':
        await suppressAddress(event.email);
        break;
      case 'unsubscribe':
        await markUnsubscribed(event.email);
        break;
      case 'delivered':
        await recordDelivery(event.sg_message_id);
        break;
    }
  }

  return NextResponse.json({ ok: true });
}

async function markAddressUndeliverable(email: string, reason?: string) {
  // flag address in DB, stop further sends
}

async function suppressAddress(email: string) {
  // add to internal suppression list
}

async function markUnsubscribed(email: string) {
  // honor the unsubscribe in DB
}

async function recordDelivery(messageId: string) {
  // store delivery confirmation
}

Notice that the signature header is named x-twilio-email-event-webhook-signature. SendGrid is owned by Twilio, and the event webhook signature feature lives under that brand prefix. Claude often generates x-sendgrid-signature because that is the intuitive name; the actual header has the Twilio prefix.

Add a webhook section to CLAUDE.md:

## Event webhooks

- Endpoint: src/app/api/webhooks/sendgrid/route.ts
- ALWAYS verify signature with @sendgrid/eventwebhook before processing
- Headers: x-twilio-email-event-webhook-signature, x-twilio-email-event-webhook-timestamp
- SENDGRID_WEBHOOK_PUBLIC_KEY env var, configured in SendGrid dashboard under Mail Settings
- Events arrive in batches, payload is a JSON array, never a single object
- Handle: bounce, dropped, spamreport, unsubscribe, delivered (at minimum)
- bounce/dropped: mark address undeliverable, stop sending
- spamreport: suppress address immediately
- unsubscribe: honor the unsubscribe in your DB
- Return 200 for valid events, 403 for invalid signature
- Idempotent: events may be redelivered, dedupe by sg_event_id

IP warmup and dedicated IPs

If you are sending more than 50,000 emails per day, SendGrid recommends a dedicated IP. New dedicated IPs require a warmup schedule (gradually increasing daily volume over 30 days) to build sender reputation. Sending the full target volume from a cold dedicated IP triggers ISP rate-limiting and degrades deliverability across all your sends.

SendGrid's IP warmup feature in the dashboard handles this automatically when enabled on the IP. Claude does not need to know the schedule, but it should know to avoid sending non-warmup-eligible categories during the warmup window.

Add an IP and warmup section to CLAUDE.md for high-volume projects:

## Dedicated IP and warmup

- Only relevant for >50k sends/day
- Enable IP warmup in SendGrid dashboard, not in code
- During the 30-day warmup, route only opt-in transactional traffic through the dedicated IP
- Send marketing or re-engagement traffic through a shared IP during warmup
- Use categories to segment send volume by IP pool routing rules

Suppression groups

SendGrid models opt-outs as Suppression Groups (Unsubscribe Groups). Each group represents a category of communication the user can opt in or out of independently (transactional, marketing, product updates). Every send must reference at least one group and list the groups to display on the recipient's preference page.

The setup workflow in the dashboard:

  1. Settings -> Suppressions -> Unsubscribe Groups
  2. Create groups for each communication category
  3. Note the group IDs (integer, not GUID)
  4. Reference IDs in the typed registry in src/emails/templates.ts

Sending with a group:

asm: {
  groupId: SENDGRID_GROUPS.transactional,
  groupsToDisplay: [
    SENDGRID_GROUPS.transactional,
    SENDGRID_GROUPS.marketing,
    SENDGRID_GROUPS.productUpdates,
  ],
}

groupId is the group this send belongs to. groupsToDisplay is the set of groups shown on the unsubscribe preference page. If you only show one group, recipients can only opt out of that one group. If you list all groups, they can manage all preferences from one click.

For transactional categories that should bypass opt-outs entirely (password resets, security alerts, billing notices), assign them to a group marked as Transactional in the SendGrid dashboard. Transactional groups bypass user-level suppression because they are required communications.

Common Claude Code mistakes with SendGrid

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. setApiKey() inside route handlers

Claude generates: sgMail.setApiKey(process.env.SENDGRID_API_KEY) at the top of every route file.

Correct pattern: one setApiKey() call in src/lib/sendgrid.ts, the module that every send imports.

2. Top-level subject with templateId

Claude generates: { to, from, subject: '...', templateId: 'd-...', dynamicTemplateData: {...} }.

Correct pattern: omit subject, let the template define it, or put it in personalizations[].subject.

3. to: [] array for batching

Claude generates: to: ['user1@x.com', 'user2@y.com', 'user3@z.com'] for multi-recipient sends.

Correct pattern: personalizations: [{ to: 'user1@x.com' }, { to: 'user2@y.com' }, { to: 'user3@z.com' }].

4. Missing asm field

Claude generates: sends with no suppression group, breaking CAN-SPAM compliance.

Correct pattern: every send has asm: { groupId, groupsToDisplay }.

5. No event webhook signature verification

Claude generates: a webhook handler that parses req.body and acts on the data.

Correct pattern: EventWebhook.verifySignature() before any processing.

6. Wrong webhook signature header name

Claude generates: req.headers.get('x-sendgrid-signature').

Correct pattern: req.headers.get('x-twilio-email-event-webhook-signature').

Add these as before/after pairs in CLAUDE.md.

Permission hooks for SendGrid scripts

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(node scripts/preview-template.js*)",
      "Bash(node scripts/list-templates.js*)",
      "Bash(node scripts/check-sender-auth.js*)"
    ],
    "deny": [
      "Bash(node scripts/send-broadcast.js*)",
      "Bash(node scripts/purge-suppressions.js*)",
      "Bash(node scripts/replay-events.js*)"
    ]
  }
}

For more on permission patterns, Claude Code hooks covers the full configuration model.

Building SendGrid integrations that scale

The SendGrid CLAUDE.md in this guide produces email integrations where every send carries a plain text fallback, uses personalizations for batching, references a suppression group, sets categories for analytics, attaches customArgs for cross-referencing, validates event webhook signatures, and uses dynamic templates with a typed registry instead of inline HTML.

The underlying principle is that SendGrid is a deliverability platform first and an API second. The shape of the API reflects deliverability concerns: suppression groups for compliance, personalizations for privacy, signed event webhooks for security, dynamic templates for content-versus-code separation. Without CLAUDE.md, Claude generates the simpler shape that the SendGrid quickstart docs show, which works in development and fails as soon as volume, compliance, or analytics matter.

For multi-channel notifications, Claude Code with Twilio covers the SMS side, and Claude Code with Resend is the developer-first email alternative for projects that do not need SendGrid's scale. Get Claudify. The bundled SendGrid template ships with the personalizations pattern, suppression groups, event webhook verification, and typed template registry pre-configured.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame