SendGrid Integration Guide
███████╗███████╗███╗ ██╗██████╗ ██████╗ ██████╗ ██╗██████╗
██╔════╝██╔════╝████╗ ██║██╔══██╗██╔════╝ ██╔══██╗██║██╔══██╗
███████╗█████╗ ██╔██╗ ██║██║ ██║██║ ███╗██████╔╝██║██║ ██║
╚════██║██╔══╝ ██║╚██╗██║██║ ██║██║ ██║██╔══██╗██║██║ ██║
███████║███████╗██║ ╚████║██████╔╝╚██████╔╝██║ ██║██║██████╔╝
╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═════╝SendGrid Integration Guide
Focus: When and how codeAmani uses Twilio SendGrid for client projects that specifically require it — sending with the
@sendgrid/mailSDK, 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-stackMCP (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
| Resource | URL |
|---|---|
| SendGrid Docs (root) | https://docs.sendgrid.com |
| Mail Send — Web API v3 | https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send |
| Dynamic Templates | https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates |
| Event Webhook + signed events | https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event |
| Domain Authentication (SPF/DKIM) | https://www.twilio.com/docs/sendgrid/ui/account-and-settings/how-to-set-up-domain-authentication |
| Node.js SDK | https://github.com/sendgrid/sendgrid-nodejs |
SDK Setup
npm install @sendgrid/mail # sending email
npm install @sendgrid/client # raw Web API v3 (suppressions, templates, stats)
npm install @sendgrid/eventwebhook # Event Webhook signature verificationInitialize the Client
// 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 domainCore 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.
// 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:
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
fromaddress must be a Single Sender or, in production, an address on an authenticated domain (below). An unverifiedfromreturns403 ForbiddenwithThe 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:
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:
- 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. - 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 SPFinclude. - Add all three CNAMEs in Porkbun exactly as given (host + target), then click Verify in SendGrid.
- Add a DMARC policy yourself — SendGrid does not create it. Start in monitor mode, then tighten:
# 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=1Gotcha — 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 atp=noneand aligned reports look clean, move top=quarantinethenp=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.
// 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; callingreq.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 onsg_event_idbefore acting, and ACK with2xxquickly — 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.
// 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.
- Add an MX record for the receiving subdomain in Porkbun:
Type: MX Host: parse Priority: 10 Target: mx.sendgrid.net. - SendGrid → Settings → Inbound Parse → add host
parse.client.example→ destination URLhttps://app.client.example/api/inbound.
// 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-stackMCP before choosing. The house default is Resend + React Email for transactional email; SendGrid is a client-driven exception, not a default.
| Concern | House default (Resend) | SendGrid (client-required) |
|---|---|---|
| Primary use | codeAmani products, transactional | Client owns SendGrid / high-volume / Twilio-stack |
| Templates | React Email components (.tsx) | Dashboard Handlebars dynamic templates (d-...) |
| SDK | resend | @sendgrid/mail + @sendgrid/client |
| Webhook signing | Svix signature | ECDSA via @sendgrid/eventwebhook |
| Marketing campaigns | Not the focus | First-class (lists, segments, dedicated IPs) |
| Multi-tenant sending | Single account | Sub-users with isolated reputation/stats |
| When to pick | Default — start here | Only when a client requires SendGrid specifically |
Environment Variables
# 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-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAdd these to
ENV_MASTER.mdand 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 Case | Approach |
|---|---|
| Default transactional email | Resend + React Email (house default) — use SendGrid only if the client requires it |
| Templated transactional send | sgMail.send with templateId + dynamicTemplateData |
| One-off system alert | sgMail.send with subject/text/html, no template |
| Per-recipient batch | personalizations or isMultiple: true to avoid leaking the list |
| Delivery / bounce tracking | Signed Event Webhook → verify → update DB + suppress |
| Honor opt-outs | Mirror suppressions locally; check before every send |
| Reply / intake by email | Inbound Parse → MX record + multipart webhook |
| Deliverability | Authenticate the domain (DKIM CNAMEs) + add DMARC |
| Multi-tenant client | SendGrid sub-users, one per tenant |
Troubleshooting
| Issue | Fix |
|---|---|
401 Unauthorized | SENDGRID_API_KEY missing/typo, or the key was revoked — recreate it |
403 Forbidden on send | from 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 403 | You 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 prod | A proxy/body-parser reserialized the body — ensure raw bytes reach the verifier (Node runtime, no JSON middleware) |
| Duplicate event processing | Dedupe on sg_event_id; SendGrid batches and can redeliver |
| Template renders blank vars | dynamicTemplateData keys must match the Handlebars {{vars}}; subject lives in the template, not the send call |
| Recipients see each other | Use isMultiple: true or personalizations, not a bare to: [] |
| Mail to an address silently never arrives | It's on a SendGrid suppression list — check /v3/suppression/* and clear if appropriate |
| Inbound Parse never fires | MX record for the parse subdomain missing/incorrect in Porkbun (mx.sendgrid.net, priority 10) |