Loading tutorials…
Loading tutorials…
Event Webhooks are how your app actually learns what happened to a send — delivered, opened, clicked, bounced, complained. The setup is 5 minutes; the production-grade version (signature verification, idempotency, retry handling) is 2 hours. Most teams skip the second part and find out at month 6 when payloads start no-op'ing.
Who this is forEngineers who need to know per-email outcomes in their own DB. If you currently rely on SendGrid's Activity Feed in the UI to debug 'did this user receive the email?' — webhooks let you answer that from your own DB in real time.
What you'll need
Step 1
Create an email_events table keyed on (sg_message_id, event, timestamp). Index for fast joins to your email_sends table.
Schema (Postgres example):
`CREATE TABLE email_events (id SERIAL PRIMARY KEY, sg_message_id VARCHAR(64) NOT NULL, event VARCHAR(32) NOT NULL, email VARCHAR(255), timestamp BIGINT NOT NULL, sg_event_id VARCHAR(64) UNIQUE NOT NULL, reason TEXT, url TEXT, ip VARCHAR(45), user_agent TEXT, raw_payload JSONB, created_at TIMESTAMPTZ DEFAULT now());`
Critical: `sg_event_id` UNIQUE constraint — this is your idempotency key. SendGrid retries on 5xx + network failures and re-sends batches; the UNIQUE constraint prevents duplicate inserts.
Index: `CREATE INDEX idx_email_events_message_id ON email_events(sg_message_id);` — for joining to email_sends by message ID.
Index: `CREATE INDEX idx_email_events_email_event ON email_events(email, event);` — for "did this user bounce?" queries.
Store `raw_payload` as JSONB for forensic debugging. Costs ~1KB per event, worth it for the first 12 months of incidents.
Step 2
Create a POST endpoint at /api/webhooks/sendgrid. Accept JSON array, iterate events, upsert with idempotency.
Endpoint path: /api/webhooks/sendgrid (or wherever your routing lives). Method: POST. Content-Type: application/json.
Payload shape: SendGrid sends a JSON ARRAY of events in one POST. Typical batch is 100-1000 events.
Each event has: `event` (delivered/opened/clicked/bounce/dropped/spamreport/unsubscribe), `email`, `timestamp` (Unix), `sg_message_id`, `sg_event_id`, plus event-specific fields (e.g., `url` for clicked, `reason` for bounced).
Process: iterate the array, insert each event using `INSERT ... ON CONFLICT (sg_event_id) DO NOTHING`. Idempotent — re-running the same batch is safe.
Return 200 OK within 30 seconds. SendGrid retries on non-2xx or timeout. If your processing takes longer, queue the work asynchronously and return 200 immediately.
Node example (Express): `app.post('/api/webhooks/sendgrid', async (req, res) => { for (const event of req.body) { await db.query('INSERT INTO email_events (...) VALUES (...) ON CONFLICT (sg_event_id) DO NOTHING', [...]); } res.status(200).send('ok'); });`
Step 3
SendGrid → Settings → Mail Settings → Event Webhook. Add HTTP POST URL. Enable Signed Event Webhook for security.
SendGrid → Settings → Mail Settings → Event Webhook → toggle ON.
HTTP POST URL: enter your endpoint (e.g., `https://yourbrand.com/api/webhooks/sendgrid`). Must be HTTPS in 2026 — HTTP endpoints are rejected.
Test endpoint: click "Test Your Integration." SendGrid sends a sample event. Verify your endpoint receives + processes it.
Select events: choose which event types to receive. Recommended baseline: Processed, Delivered, Bounce, Dropped, Deferred, Spam Report, Unsubscribe, Group Unsubscribe, Open, Click. (You can disable Open later if Apple MPP noise overwhelms.)
CRITICAL: enable Signed Event Webhook. Toggle ON → SendGrid generates an ECDSA public key.
Copy the public key. Save in your env vars as `SENDGRID_WEBHOOK_PUBLIC_KEY`. You'll use this in Step 4 to verify payloads cryptographically.
Step 4
Every signed request includes `X-Twilio-Email-Event-Webhook-Signature` and `X-Twilio-Email-Event-Webhook-Timestamp` headers. Verify both before processing.
Without signature verification, any attacker can POST fake events to your webhook URL (which is public). They could mark every user as "bounced," "unsubscribed," etc.
Headers SendGrid sends: `X-Twilio-Email-Event-Webhook-Signature` (base64 ECDSA signature) and `X-Twilio-Email-Event-Webhook-Timestamp` (Unix timestamp).
Verification: signature = ECDSA(timestamp + raw_request_body, sendgrid_public_key). Use Node `crypto.createVerify('sha256')` or Python `ecdsa` library.
Node example: `const verifier = crypto.createVerify('sha256'); verifier.update(timestamp + rawBody); const isValid = verifier.verify(publicKey, signature, 'base64');`
Critical: use the RAW request body, not the parsed JSON. Most frameworks parse JSON before your handler runs — configure Express/Next.js to also expose the raw body for this route.
Reject any request where verification fails. Return 401 Unauthorized.
Also check timestamp freshness — reject requests older than 10 minutes to prevent replay attacks.
Step 5
Join email_events to email_sends on sg_message_id. Update per-send status: latest event wins for status column.
After each insert (or as a periodic job), update your email_sends table with the latest event status:
`UPDATE email_sends SET status = e.event, last_event_at = e.timestamp FROM (SELECT DISTINCT ON (sg_message_id) sg_message_id, event, timestamp FROM email_events ORDER BY sg_message_id, timestamp DESC) e WHERE email_sends.sg_message_id = e.sg_message_id;`
This gives you per-send latest status: "delivered," "opened," "bounced," etc. Now you can answer "did this user receive the email?" from your DB in O(1).
For per-user state: aggregate to a user_email_state table. "User's last bounce date," "user's last unsubscribe date," "user's 30-day open count." Compute via materialized view or scheduled job.
Critical: handle out-of-order events. SendGrid retries can deliver events out of timestamp order. Use the latest BY TIMESTAMP, not by insertion order.
Step 6
When a bounce or complaint event arrives, mark the user as 'no-email' in your app. Prevent future sends to that address.
Bounce types: hard bounce (permanent — bad address, domain gone) and soft bounce (temporary — mailbox full, server down). Hard bounces should permanently suppress; soft bounces wait for SendGrid's automated handling (5+ soft bounces in 30 days → upgrades to hard).
On hard bounce event: update user record `email_status = 'bounced'`. Block future sends to this address from your app.
On complaint event (spam report): update `email_status = 'complained'`. Block ALL future sends. Complaint-rate spike is the fastest way to damage reputation.
On unsubscribe event: update `marketing_consent = false`. Allow transactional emails but block marketing.
Don't try to override SendGrid's suppressions. SendGrid maintains its own suppression list — your app's block is BELT, SendGrid's suppression is SUSPENDERS. Both belong.
Sync direction: events from SendGrid → your DB. NEVER push your bounces/complaints back to SendGrid via API (creates loops + race conditions).
Step 7
Set up monitoring for webhook delivery rate. Alert when received-events drops significantly below expected sends.
Compute daily ratio: events_received / sends_attempted. Healthy: ~3-5 events per send (processed + delivered + opened + clicked, on average).
Alert when ratio drops below 1.0 — suggests events are being dropped (your endpoint returning 5xx, network issues, or signature verification rejecting legitimate requests).
SendGrid retries failed webhook POSTs for up to 24 hours with exponential backoff. After 24 hours, events are dropped permanently. Catch failures quickly.
SendGrid → Settings → Mail Settings → Event Webhook → Stats: SendGrid surfaces delivery rate per-endpoint. Check weekly.
Log every signature verification failure separately. A sudden spike of failures means SendGrid rotated the signing key (rare but happens) or your env var is wrong.
Common mistakes
No signature verification on the webhook endpoint
What goes wrong: Anyone with your webhook URL can POST fake events. Attacker marks every user as 'bounced' or 'unsubscribed' → your app stops sending real emails. Recovery requires manually clearing the fake events and trusting your data again.
How to avoid: Enable Signed Event Webhook in SendGrid + verify the signature on every request. Reject unverified requests with 401. Use the raw request body, not parsed JSON, for verification.
No idempotency on event inserts
What goes wrong: SendGrid retries failed batches. Without `ON CONFLICT (sg_event_id) DO NOTHING`, you double-insert events. Open counts double, click counts double, complaint counts double. Reports are wrong.
How to avoid: UNIQUE constraint on sg_event_id. Use `INSERT ... ON CONFLICT (sg_event_id) DO NOTHING` (Postgres) or equivalent (MySQL `INSERT IGNORE`, SQLite `INSERT OR IGNORE`).
Processing events synchronously, hitting 30s timeout
What goes wrong: Heavy per-event work (downstream API calls, complex DB updates) blocks the endpoint. SendGrid times out → retries → you get the same batch 3-5 times. Either you OOM or events get dropped after 24h of retries.
How to avoid: Insert event to DB, return 200 immediately. Process async via background job reading from email_events table. Decouples webhook response from per-event work.
Trusting timestamp order instead of using actual timestamps
What goes wrong: Out-of-order delivery means newer events arrive first sometimes. If you set status from insertion order, you'll mark a delivered message as 'processed' (older event) AFTER it was already delivered. Status is wrong.
How to avoid: Always use event timestamp (Unix) for ordering. Status = latest event by timestamp, not by row insertion order. Use `DISTINCT ON (sg_message_id) ORDER BY sg_message_id, timestamp DESC` in Postgres.
Not storing X-Message-Id with sends
What goes wrong: Webhook events include `sg_message_id` but you have nothing to join to. You can't answer 'did this user receive this welcome email?' — you'd have to query SendGrid's Activity API for every question.
How to avoid: Tutorial 3, Step 5: store X-Message-Id (from API response) or SMTP queue ID with every send in your email_sends table. Index it. Webhook events match by this column.
Pushing your own suppressions back to SendGrid
What goes wrong: You think you're 'keeping them in sync' but SendGrid + your app both maintain suppression independently. Pushing creates loops (your app sees the suppression, pushes back, SendGrid re-syncs, app sees it again). Race conditions corrupt data.
How to avoid: One-way flow: SendGrid events → your DB → your app blocks sends. Never push the other direction. SendGrid manages its own suppressions; trust them.
Recap
Done — what's next
SendGrid Web API vs SMTP Relay — the honest decision and full setup for each
Read the next tutorial
Hand it off
Webhooks look like a 30-minute task and run that way for 90 days — until the first dropped batch, double-counted complaint, or signature-verification gap. A specialist who has built 30+ webhook integrations will ship signature verification + idempotency + reconciliation + monitoring in 6-8 hours. Typical engagement is $500-1,000 at $14-16/hr.
See specialist rates
SendGrid batches events for up to 30 seconds OR up to 1000 events, whichever comes first. High-volume senders see 1-2 batches per minute; low-volume senders see batches every 30 seconds. Each batch is one POST containing a JSON array.
SendGrid retries with exponential backoff for 24 hours. After 24 hours of failure, events are dropped permanently. Set up monitoring + alerting so you notice within hours, not days. SendGrid Stats shows per-endpoint delivery rate.
Enable all of them initially — easier to disable later than to miss something you needed. The critical ones: Bounce, Dropped, Spam Report, Unsubscribe. The nice-to-haves: Open, Click. The mostly-noise ones: Processed, Deferred (you can disable these if your DB is growing fast).
Three possibilities: (1) Apple MPP pre-fetches images, triggering opens (filter out by user agent containing 'Mail Privacy' or similar), (2) your webhook URL is being spammed by an attacker — enable signature verification, (3) a colleague is testing in dev and hitting production webhook — use separate endpoints per env.
12-24 months is typical. Beyond that, JSONB raw_payload storage gets expensive. Aggregate per-user state into a smaller table; drop raw events older than 12 months. SendGrid's Activity Feed only goes back 30 days, so your DB is the long-term system of record.
No — different products. Inbound Parse is for receiving INCOMING emails to your domain (e.g., reply-handling). Event Webhook is for events about OUTGOING emails you sent. Use both if you need both, but they're orthogonal features.
SendGrid
Web API or SMTP Relay is one of those decisions that looks like '5 minutes of research' and turns into a 2-week migration when you pick wrong. The defaults each tutorial pushes are usually backward for your real use case. Here's the honest tradeoff and complete setup for both.
SendGrid
Dynamic Templates are SendGrid's best-kept feature: template lives in SendGrid, your app sends a small JSON payload, marketing edits the design without touching code. But the Handlebars syntax, versioning, and dynamic_template_data shape have sharp edges that cost an afternoon to learn the hard way.
SendGrid
Suppressions are the dull, unglamorous part of email that decides whether your account survives the year. Gmail and Yahoo's 2024 bulk sender rules made one-click unsubscribe mandatory at scale, and SendGrid's suppression groups are how you respect that without losing transactional sends. Here's the full setup.
SendGrid
Open rate dropped from 28% to 14%. Bounces jumped. Customer support is forwarding 'we never got the email' tickets. The instinct is 'subject lines' or 'content' — usually it's deliverability infrastructure. Here's how specialists diagnose SendGrid deliverability without guessing.
SendGrid
DIY SendGrid is the right call until it isn't. The signal isn't 'sending more emails' — it's that the cost-of-mistakes finally outweighs the cost-of-hiring. Here's the honest framework for when that line is crossed.