← Back to dashboard
sendgridcommsfresh

SendGrid Integration Guide

Text
███████╗███████╗███╗   ██╗██████╗  ██████╗ ██████╗ ██╗██████╗
██╔════╝██╔════╝████╗  ██║██╔══██╗██╔════╝ ██╔══██╗██║██╔══██╗
███████╗█████╗  ██╔██╗ ██║██║  ██║██║  ███╗██████╔╝██║██║  ██║
╚════██║██╔══╝  ██║╚██╗██║██║  ██║██║   ██║██╔══██╗██║██║  ██║
███████║███████╗██║ ╚████║██████╔╝╚██████╔╝██║  ██║██║██████╔╝
╚══════╝╚══════╝╚═╝  ╚═══╝╚═════╝  ╚═════╝ ╚═╝  ╚═╝╚═╝╚═════╝

SendGrid Integration Guide

Focus: When and how codeAmani uses Twilio SendGrid for client projects that specifically require it — sending with the @sendgrid/mail SDK, dynamic templates, domain authentication (SPF/DKIM/DMARC) for deliverability, a signature-verified Event Webhook, suppression management, and inbound parse. The house default remains Resend + React Email.

Overview

SendGrid (Twilio SendGrid) is a high-volume email platform with a mature Web API v3, marketing campaigns, dynamic Handlebars templates, sub-user/multi-tenant sending, and a rich event-tracking pipeline. It is advertised as an email option on motionstackstudios.com.

For codeAmani's own products, transactional email is Resend + React Email — cleaner DX, React-rendered templates, fewer moving parts (see the resend guide). SendGrid earns a place only on client engagements that require it:

  • the client already runs a SendGrid account and won't migrate,
  • high-volume marketing + transactional that benefits from SendGrid's campaign tooling and dedicated IPs,
  • sub-user / multi-tenant sending where each tenant needs isolated reputation and stats,
  • a client consolidating on the Twilio stack (SMS + email under one vendor/bill).

The core send path plus the event-feedback loop:

Check first. Before wiring SendGrid into a build, query the codeAmani-tech-stack MCP (search_guides / get_guide) and confirm the house Resend default genuinely can't serve the requirement. SendGrid adds vendor surface and a second email-deliverability footprint — prefer Resend unless the client specifically needs SendGrid.

Official Documentation


SDK Setup

Bash
npm install @sendgrid/mail        # sending email
npm install @sendgrid/client      # raw Web API v3 (suppressions, templates, stats)
npm install @sendgrid/eventwebhook   # Event Webhook signature verification

Initialize the Client

TypeScript
// 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 { sgMail };
export const FROM = process.env.SENDGRID_FROM_EMAIL!; // must be a verified sender / authenticated domain

Core Patterns

Send a Templated Email (Dynamic Templates)

SendGrid's dynamic templates are Handlebars templates created in the dashboard (Email API → Dynamic Templates). Each version compiles to HTML; you reference it by templateId (always starts with d-) and pass dynamicTemplateData. The subject and body live in the template, so you do not set subject/html here — the template owns them.

TypeScript
// app/api/email/welcome/route.ts
import { NextRequest } from "next/server";
import { sgMail, FROM } from "@/lib/sendgrid";

export async function POST(req: NextRequest) {
  const { to, userName, dashboardUrl } = await req.json();

  try {
    await sgMail.send({
      to,
      from: FROM,
      templateId: process.env.SENDGRID_WELCOME_TEMPLATE_ID!, // "d-..."
      dynamicTemplateData: {
        userName,
        dashboardUrl,
        year: new Date().getFullYear(),
      },
    });
    return Response.json({ ok: true });
  } catch (error: any) {
    // SendGrid surfaces field-level errors in error.response.body.errors
    const body = error?.response?.body;
    console.error("sendgrid error", body ?? error);
    return Response.json({ error: body?.errors ?? "send failed" }, { status: 502 });
  }
}

Send Plain HTML (no template)

For one-off system alerts where a dashboard template is overkill:

TypeScript
await sgMail.send({
  to: "ops@client.example",
  from: FROM,
  subject: "Nightly job failed",
  text: "The reconciliation job exited non-zero. Check the logs.",
  html: "<p>The reconciliation job exited non-zero. Check the logs.</p>",
});

Gotcha — verified sender required. The from address must be a Single Sender or, in production, an address on an authenticated domain (below). An unverified from returns 403 Forbidden with The from address does not match a verified Sender Identity.

Multiple Recipients Without Leaking the List

By default a to: [] array exposes every recipient to each other. Use isMultiple: true so SendGrid sends an individual message per recipient, or use personalizations for per-recipient template data:

TypeScript
await sgMail.send(
  {
    to: ["a@example.com", "b@example.com"],
    from: FROM,
    templateId: process.env.SENDGRID_DIGEST_TEMPLATE_ID!,
    dynamicTemplateData: { week: "2026-W25" },
  },
  /* isMultiple */ true,
);

Domain Authentication (SPF / DKIM / DMARC)

Deliverability is the whole game. A SendGrid send from an unauthenticated domain lands in spam or gets the via sendgrid.net "on behalf of" stamp. Authenticate the domain before sending anything real. codeAmani manages client DNS through Porkbun (see the porkbun-dns guide).

Checklist:

  1. SendGrid → Settings → Sender Authentication → Authenticate Your Domain. Pick the DNS host (Porkbun / "Other") and the domain (e.g. mail.client.example). Disable link branding only if the client has a reason to.
  2. SendGrid issues three CNAME records — two DKIM signing keys (s1._domainkey, s2._domainkey) and a return-path / mail CNAME. SendGrid manages SPF for you behind the return-path CNAME, so you normally do not hand-author an SPF include.
  3. Add all three CNAMEs in Porkbun exactly as given (host + target), then click Verify in SendGrid.
  4. Add a DMARC policy yourself — SendGrid does not create it. Start in monitor mode, then tighten:
Bash
# Porkbun DNS records
# DKIM / return-path: add the 3 CNAMEs exactly as SendGrid lists them, e.g.
#   Type: CNAME  Host: s1._domainkey  Target: s1.domainkey.uXXXX.wlYYY.sendgrid.net
#   Type: CNAME  Host: s2._domainkey  Target: s2.domainkey.uXXXX.wlYYY.sendgrid.net
#   Type: CNAME  Host: em1234         Target: uXXXX.wlYYY.sendgrid.net
#
# DMARC (author this yourself — start at p=none and watch reports):
#   Type: TXT  Host: _dmarc  Value: v=DMARC1; p=none; rua=mailto:dmarc@client.example; fo=1

Gotcha — Porkbun and the apex. Add the CNAMEs on the subdomain host SendGrid specifies (often em####, s1._domainkey, s2._domainkey), not the apex. Porkbun won't let a CNAME coexist on a host that already has other records — give SendGrid its own subdomain. Once DMARC is at p=none and aligned reports look clean, move to p=quarantine then p=reject.


Event Webhook (Signed Event Verification)

SendGrid POSTs batched JSON arrays of delivery and engagement events (delivered, bounce, dropped, deferred, open, click, spamreport, unsubscribe, ...). Enable Signed Event Webhook Requests (Settings → Mail Settings → Event Webhook) and SendGrid signs each request with an ECDSA key; it gives you the Base64 public verification key. This is the SendGrid analogue of the Svix-signed Resend/Clerk webhooks in the webhooks and resend guides — never trust an unsigned event.

Two non-negotiables: verify against the raw request body (any reserialization breaks the signature), and pass the two headers via the SDK's EventWebhookHeader helpers.

TypeScript
// app/api/webhooks/sendgrid/route.ts  (Next.js App Router — Node runtime)
import { EventWebhook, EventWebhookHeader } from "@sendgrid/eventwebhook";
import { NextRequest } from "next/server";

export const runtime = "nodejs";        // ECDSA verify needs Node crypto, not edge
export const dynamic = "force-dynamic";

interface SgEvent {
  email: string;
  event:
    | "delivered" | "bounce" | "dropped" | "deferred"
    | "open" | "click" | "spamreport" | "unsubscribe";
  timestamp: number;
  sg_event_id: string;
  reason?: string;
  url?: string;
}

export async function POST(req: NextRequest) {
  // 1) Read the RAW body — do not JSON.parse before verifying.
  const rawBody = await req.text();

  const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
  const signature = req.headers.get(EventWebhookHeader.SIGNATURE());   // X-Twilio-Email-Event-Webhook-Signature
  const timestamp = req.headers.get(EventWebhookHeader.TIMESTAMP());   // X-Twilio-Email-Event-Webhook-Timestamp

  if (!publicKey || !signature || !timestamp) {
    return new Response("missing signature material", { status: 400 });
  }

  // 2) Verify the ECDSA signature over (timestamp + rawBody).
  const ew = new EventWebhook();
  const ecKey = ew.convertPublicKeyToECDSA(publicKey);
  const valid = ew.verifySignature(ecKey, rawBody, signature, timestamp);
  if (!valid) {
    return new Response("invalid signature", { status: 403 });
  }

  // 3) Now it's safe to parse and process the batch.
  const events: SgEvent[] = JSON.parse(rawBody);
  for (const e of events) {
    switch (e.event) {
      case "delivered":
        // mark delivered in DB
        break;
      case "bounce":
      case "dropped":
        // hard failure — add to your local suppression mirror, stop sending
        await suppressLocally(e.email, e.reason);
        break;
      case "spamreport":
      case "unsubscribe":
        // honor opt-out — never email again
        await markUnsubscribed(e.email);
        break;
      case "open":
      case "click":
        // engagement analytics (e.url for clicks)
        break;
    }
  }

  // 4) ACK fast. SendGrid retries non-2xx; 2xx stops retries.
  return new Response(null, { status: 204 });
}

declare function suppressLocally(email: string, reason?: string): Promise<void>;
declare function markUnsubscribed(email: string): Promise<void>;

Gotcha — raw body + idempotency. In the App Router, await req.text() gives you the exact bytes SendGrid signed; calling req.json() first (or running through a body parser) will reserialize and the signature check fails with a valid request. Events arrive batched and can be redelivered, so dedupe on sg_event_id before acting, and ACK with 2xx quickly — slow handlers trigger SendGrid's retry storm.


Suppression Management

SendGrid maintains server-side suppression lists (bounces, blocks, spam reports, unsubscribes, invalid emails) and will not deliver to a suppressed address. Mirror these locally from the Event Webhook so your app never re-queues a dead address, and read/clear them via @sendgrid/client when a user genuinely re-opts-in.

TypeScript
// lib/sendgrid-suppressions.ts
import client from "@sendgrid/client";

client.setApiKey(process.env.SENDGRID_API_KEY!);

/** Remove an address from the global bounce list (e.g. after a typo fix). */
export async function clearBounce(email: string): Promise<void> {
  await client.request({
    method: "DELETE",
    url: `/v3/suppression/bounces/${encodeURIComponent(email)}`,
  });
}

/** Check whether an address is on the global unsubscribe list. */
export async function isUnsubscribed(email: string): Promise<boolean> {
  const [, body] = await client.request({
    method: "GET",
    url: "/v3/suppression/unsubscribes",
    qs: { email },
  });
  return Array.isArray(body) && body.length > 0;
}

Treat SendGrid's suppression list as the source of truth and your local mirror as a fast pre-check. Never strip an unsubscribe — it's a legal (CAN-SPAM) and reputation obligation.


Inbound Parse

Inbound Parse turns received email into a webhook POST (multipart form) to your app — useful for reply-to-ticket flows, document intake by email, or +tag routing.

  1. Add an MX record for the receiving subdomain in Porkbun: Type: MX Host: parse Priority: 10 Target: mx.sendgrid.net.
  2. SendGrid → Settings → Inbound Parse → add host parse.client.example → destination URL https://app.client.example/api/inbound.
TypeScript
// app/api/inbound/route.ts  — SendGrid posts multipart/form-data
import { NextRequest } from "next/server";

export const runtime = "nodejs";

export async function POST(req: NextRequest) {
  const form = await req.formData();
  const from = String(form.get("from") ?? "");
  const subject = String(form.get("subject") ?? "");
  const text = String(form.get("text") ?? "");      // plain-text body
  const attachmentCount = Number(form.get("attachments") ?? 0);

  // route by +tag, file a ticket, persist attachments, etc.
  await handleInbound({ from, subject, text, attachmentCount });

  return new Response(null, { status: 200 });
}

declare function handleInbound(msg: {
  from: string; subject: string; text: string; attachmentCount: number;
}): Promise<void>;

Inbound Parse has no signed-event mechanism like the Event Webhook. Guard the endpoint with a hard-to-guess path plus a shared secret in the URL, validate the from/SPF if it matters, and treat all content as untrusted user input.


SendGrid vs the House Resend Default

Consult the codeAmani-tech-stack MCP before choosing. The house default is Resend + React Email for transactional email; SendGrid is a client-driven exception, not a default.

ConcernHouse default (Resend)SendGrid (client-required)
Primary usecodeAmani products, transactionalClient owns SendGrid / high-volume / Twilio-stack
TemplatesReact Email components (.tsx)Dashboard Handlebars dynamic templates (d-...)
SDKresend@sendgrid/mail + @sendgrid/client
Webhook signingSvix signatureECDSA via @sendgrid/eventwebhook
Marketing campaignsNot the focusFirst-class (lists, segments, dedicated IPs)
Multi-tenant sendingSingle accountSub-users with isolated reputation/stats
When to pickDefault — start hereOnly when a client requires SendGrid specifically

Environment Variables

Bash
# Required
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxxx   # create as "Restricted Access" (Mail Send only) where possible
SENDGRID_FROM_EMAIL=no-reply@mail.client.example   # verified sender / authenticated domain

# Event Webhook (Base64 public key from Mail Settings → Event Webhook → Signed)
SENDGRID_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...

# Dynamic template IDs (always start with d-)
SENDGRID_WELCOME_TEMPLATE_ID=d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
SENDGRID_DIGEST_TEMPLATE_ID=d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Add these to ENV_MASTER.md and each project's .env.example. Use a Restricted Access API key scoped to Mail Send for the app; reserve a full-access key for admin scripts only. The API key is server-side only — never ship it to the client bundle.


Common Use Cases

Use CaseApproach
Default transactional emailResend + React Email (house default) — use SendGrid only if the client requires it
Templated transactional sendsgMail.send with templateId + dynamicTemplateData
One-off system alertsgMail.send with subject/text/html, no template
Per-recipient batchpersonalizations or isMultiple: true to avoid leaking the list
Delivery / bounce trackingSigned Event Webhook → verify → update DB + suppress
Honor opt-outsMirror suppressions locally; check before every send
Reply / intake by emailInbound Parse → MX record + multipart webhook
DeliverabilityAuthenticate the domain (DKIM CNAMEs) + add DMARC
Multi-tenant clientSendGrid sub-users, one per tenant

Troubleshooting

IssueFix
401 UnauthorizedSENDGRID_API_KEY missing/typo, or the key was revoked — recreate it
403 Forbidden on sendfrom is not a verified Sender / authenticated domain — verify it first
Emails land in spam / "via sendgrid.net"Domain not authenticated — add the DKIM CNAMEs and DMARC, then re-verify
Webhook always returns 403You parsed the body before verifying — verify against the raw req.text(); confirm SENDGRID_WEBHOOK_PUBLIC_KEY matches the dashboard key
Webhook signature valid locally, fails in prodA proxy/body-parser reserialized the body — ensure raw bytes reach the verifier (Node runtime, no JSON middleware)
Duplicate event processingDedupe on sg_event_id; SendGrid batches and can redeliver
Template renders blank varsdynamicTemplateData keys must match the Handlebars {{vars}}; subject lives in the template, not the send call
Recipients see each otherUse isMultiple: true or personalizations, not a bare to: []
Mail to an address silently never arrivesIt's on a SendGrid suppression list — check /v3/suppression/* and clear if appropriate
Inbound Parse never firesMX record for the parse subdomain missing/incorrect in Porkbun (mx.sendgrid.net, priority 10)