SendGrid Integration
Receive and route SendGrid Event Webhooks for email delivery tracking, engagement analytics, and compliance events.
Warning
Batched Events
SendGrid sends events as a JSON array (batch of events), not as individual objects. Each webhook request may contain multiple events. See Transform Examples for how to handle this.
Setup
1. Create a Source in Hookbase
SendGrid's Signed Event Webhook uses ECDSA P-256 signatures, which Hookbase verifies natively. SendGrid
generates the key pair and shows you the public key — you paste that into Hookbase as the source's
signingSecret; there is no shared secret to make up. You get the key in step 2, so you can create
the source first and add it afterwards:
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "SendGrid Production",
"slug": "sendgrid",
"provider": "sendgrid",
"signingSecret": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...",
"rejectInvalidSignatures": true
}'Danger
Basic auth in the ingest URL is not a verification method. Earlier revisions of this page
suggested https://user:token@api.hookbase.app/ingest/.... Hookbase's ingest handler never reads
the Authorization header, so those credentials are discarded and the endpoint stays open to
anyone who learns the URL. Use SendGrid's signature verification instead.
Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/sendgrid2. Configure SendGrid Event Webhook
- Go to SendGrid Dashboard → Settings → Mail Settings
- Click Event Webhook
- Set the HTTP POST URL to your Hookbase webhook URL
- Select the events you want to receive:
- Delivery: Processed, Dropped, Delivered, Deferred, Bounce
- Engagement: Open, Click, Spam Report, Unsubscribe, Group Unsubscribe, Group Resubscribe
- Under Security features, enable Signature verification
- Click Save
- Copy the public key SendGrid now shows for this webhook and paste it into your Hookbase source
as
signingSecret
Tip
One key per webhook. Each Event Webhook has its own key pair. The public key stays visible in the
webhook's Signature verification setting (and is returned by SendGrid's API as public_key), so
you can come back for it later. If you have several Event Webhooks, give each one its own Hookbase
source with its own key.
3. Create Destinations and Routes
# Create a destination for your email event handler
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-d '{"name": "Email Analytics Service", "slug": "email-analytics-service", "url": "https://myapp.com/webhooks/sendgrid"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-d '{"name": "SendGrid to Analytics", "sourceId": "src_...", "destinationId": "dst_..."}'Signature Verification
ECDSA (Native Signed Event Webhook)
SendGrid's Signed Event Webhook uses ECDSA with the following headers:
| Header | Description |
|---|---|
X-Twilio-Email-Event-Webhook-Signature | ECDSA signature of the payload |
X-Twilio-Email-Event-Webhook-Timestamp | Timestamp used in signature generation |
Hookbase verifies the signature automatically when the source is configured with:
{
"provider": "sendgrid",
"signingSecret": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."
}The signingSecret is SendGrid's public key for the webhook — base64, exactly as SendGrid shows it,
with no PEM header. Hookbase checks the signature over the X-Twilio-Email-Event-Webhook-Timestamp
value concatenated with the raw body, and rejects timestamps more than 5 minutes old.
With rejectInvalidSignatures: true, requests whose signature does not verify get a 401 and are not
stored or delivered.
Common Events
SendGrid sends events as a JSON array. Each request may contain one or more events in a single batch.
Delivered
Sent when the receiving server accepts the email.
{
"email": "customer@example.com",
"timestamp": 1706000000,
"smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
"event": "delivered",
"category": ["welcome-emails"",
"sg_event_id": "ZGVsaXZlcmVkLTEt",
"sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
"response": "250 2.0.0 OK",
"ip": "168.1.1.1",
"tls": 1
}
]Bounce
Sent when the receiving server rejects the email.
{
"email": "invalid@example.com",
"timestamp": 1706000000,
"smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
"event": "bounce",
"category": ["transactional"",
"sg_event_id": "Ym91bmNlLTEt",
"sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
"reason": "550 5.1.1 The email account does not exist",
"type": "bounce",
"status": "5.1.1",
"ip": "168.1.1.1",
"tls": 1
}
]Open
Sent when a recipient opens an email (requires open tracking enabled).
{
"email": "customer@example.com",
"timestamp": 1706000100,
"event": "open",
"category": ["newsletter"",
"sg_event_id": "b3Blbi0xLQ",
"sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
"useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"ip": "203.0.113.1"
}
]Click
Sent when a recipient clicks a link in an email (requires click tracking enabled).
{
"email": "customer@example.com",
"timestamp": 1706000200,
"event": "click",
"category": ["newsletter"",
"sg_event_id": "Y2xpY2stMS0",
"sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
"url": "https://example.com/promo?utm_source=sendgrid",
"url_offset": {
"index": 0,
"type": "html"
},
"useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"ip": "203.0.113.1"
}
]Spam Report
Sent when a recipient marks the email as spam.
{
"email": "unsubscribed@example.com",
"timestamp": 1706000300,
"event": "spamreport",
"sg_event_id": "c3BhbXJlcG9ydC0x",
"sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
"category": ["marketing""
}
]Dropped
Sent when SendGrid drops a message (e.g., to a previously bounced or unsubscribed address).
{
"email": "bounced@example.com",
"timestamp": 1706000400,
"event": "dropped",
"sg_event_id": "ZHJvcHBlZC0xLQ",
"sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
"reason": "Bounced Address",
"status": "5.0.0"
}
"SendGrid Event Types
| Event | Description |
|---|---|
processed | Email accepted by SendGrid for delivery |
dropped | Email dropped (invalid, unsubscribed, or previously bounced) |
delivered | Email accepted by the receiving server |
deferred | Receiving server temporarily rejected the email |
bounce | Email permanently rejected by the receiving server |
open | Recipient opened the email |
click | Recipient clicked a link in the email |
spamreport | Recipient marked the email as spam |
unsubscribe | Recipient clicked the unsubscribe link |
group_unsubscribe | Recipient unsubscribed from a suppression group |
group_resubscribe | Recipient resubscribed to a suppression group |
Transform Examples
Warning
Batch Handling
SendGrid payloads are arrays of events. Your transforms receive the entire array. If you need to process events individually, iterate over the array.
Warning
A transform cannot skip a delivery. return null does not suppress anything — the engine
serializes it, so your destination receives the four-byte body null with Content-Type: text/plain. Most endpoints answer that with a 400, which Hookbase records as a failed delivery
and then retries on the route's schedule.
Skipping is a route filter, and a filter condition reads one dotted path. That is enough for a single-object payload, but it cannot express "any element of this array matches" — so for a batched payload like this one there is no filter that suppresses the uninteresting batches. Return a body your destination can discard cheaply instead, or point the route at a service of your own and let it decide whether to post.
Slack Alert for Bounces
function transform(payload) {
// payload is an array of events
const bounces = payload.filter(e => e.event === 'bounce');
// Not a skip: this body is delivered like any other. Keep it something your destination can
// recognise and drop — a Slack incoming webhook will reject it, so route batches through your
// own handler if you need real suppression.
if (bounces.length === 0) return { alert: false, reason: 'no bounce events in this batch' };
const bounceList = bounces
.map(b => `• ${b.email} — ${b.reason || 'Unknown reason'} (${b.status || 'N/A'})`)
.join('\n');
return {
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: `Email Bounce Alert (${bounces.length})`
}
},
{
type: "section",
text: {
type: "mrkdwn",
text: `*Bounced Emails:*\n${bounceList}`
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `Source: SendGrid | ${new Date().toISOString()}`
}
]
}
]
};
}Database Format
Flatten the batched events into individual records for storage:
function transform(payload) {
// payload is an array of events — map each to a normalized record
return payload.map(event => ({
sg_event_id: event.sg_event_id,
sg_message_id: event.sg_message_id,
event_type: event.event,
email: event.email,
category: Array.isArray(event.category) ? event.category.join(',') : event.category || null,
reason: event.reason || null,
bounce_type: event.type || null,
status_code: event.status || null,
url: event.url || null,
useragent: event.useragent || null,
ip: event.ip || null,
tls: event.tls || null,
timestamp: new Date(event.timestamp * 1000).toISOString(),
processed_at: new Date().toISOString()
}));
}Engagement Summary for Analytics
Aggregate events by type for a dashboard or analytics endpoint:
function transform(payload) {
const summary = payload.reduce((acc, event) => {
acc[event.event] = (acc[event.event] || 0) + 1;
return acc;
}, {});
return {
batch_size: payload.length,
event_counts: summary,
emails: [...new Set(payload.map(e => e.email))],
timestamp: new Date().toISOString()
};
}Alert for Spam Reports
function transform(payload) {
const spamReports = payload.filter(e => e.event === 'spamreport');
// Delivered, not skipped — see the callout above.
if (spamReports.length === 0) return { alert: false, reason: 'no spam reports in this batch' };
const emails = spamReports.map(e => e.email).join(', ');
return {
channel: "#email-alerts",
username: "SendGrid Bot",
icon_emoji: ":warning:",
attachments: [{
color: "danger",
title: `Spam Report (${spamReports.length})`,
fields: [
{ title: "Emails", value: emails, short: false },
{ title: "Category", value: spamReports[0].category?.join(', ') || "N/A", short: true }
],
footer: "SendGrid Event Webhook",
ts: Math.floor(Date.now() / 1000)
}]
};
}Filter Examples
Bounce Events Only
{
"name": "Bounces Only",
"logic": "OR",
"conditions": [
{
"field": "[0].event",
"operator": "equals",
"value": "bounce"
}
]
}Tip
Filtering Batched Events
Since SendGrid sends arrays, filters evaluate against the batch. For precise per-event filtering, use transforms to extract the events you need, or configure SendGrid to only send specific event types in the webhook settings.
Delivery Failure Events
{
"name": "Delivery Failures",
"logic": "OR",
"conditions": [
{
"field": "[0].event",
"operator": "equals",
"value": "bounce"
},
{
"field": "[0].event",
"operator": "equals",
"value": "dropped"
},
{
"field": "[0].event",
"operator": "equals",
"value": "deferred"
}
]
}Engagement Events
{
"name": "Engagement Events",
"logic": "OR",
"conditions": [
{
"field": "[0].event",
"operator": "equals",
"value": "open"
},
{
"field": "[0].event",
"operator": "equals",
"value": "click"
}
]
}Compliance Events
{
"name": "Compliance Events",
"logic": "OR",
"conditions": [
{
"field": "[0].event",
"operator": "equals",
"value": "spamreport"
},
{
"field": "[0].event",
"operator": "equals",
"value": "unsubscribe"
},
{
"field": "[0].event",
"operator": "equals",
"value": "group_unsubscribe"
}
]
}Headers
SendGrid sends these headers with Event Webhook requests:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | SendGrid Event API |
X-Twilio-Email-Event-Webhook-Signature | ECDSA signature, base64 (only with Signature verification enabled) |
X-Twilio-Email-Event-Webhook-Timestamp | Signature timestamp (only with Signature verification enabled) |
Hookbase does not forward these to your destination. A delivery is a fresh request with
Hookbase's own X-Delivery-ID and X-Event-ID plus the headers and auth you configured on the
destination — nothing SendGrid sent reaches your handler as a header. See
what headers a delivery carries.
Testing
Using SendGrid's Test Feature
- Go to Settings → Mail Settings → Event Webhook
- Click Test Your Integration
- SendGrid sends a sample batch of events to your URL
Manual Test
Send a test batch to verify your setup. A hand-made request carries no SendGrid signature, so it is
stored with signature_valid: false — or rejected with a 401 if rejectInvalidSignatures is on.
Use SendGrid's Test Your Integration button to exercise verification end to end:
curl -X POST https://api.hookbase.app/ingest/{orgSlug}/sendgrid \
-H "Content-Type: application/json" \
-d '[
{
"email": "test@example.com",
"timestamp": 1706000000,
"event": "delivered",
"sg_event_id": "dGVzdC0xLQ",
"sg_message_id": "test.filter0001.16648.5515E0B88.0",
"response": "250 2.0.0 OK",
"category": ["test"]
},
{
"email": "test@example.com",
"timestamp": 1706000100,
"event": "open",
"sg_event_id": "dGVzdC0yLQ",
"sg_message_id": "test.filter0001.16648.5515E0B88.0",
"useragent": "Mozilla/5.0",
"category": ["test"]
}
]'Best Practices
-
Handle batches: Always treat the payload as an array — SendGrid batches multiple events per request for efficiency
-
Use
sg_event_idfor deduplication: Each event has a uniquesg_event_idto prevent duplicate processing -
Subscribe selectively: Only enable the event types you need in SendGrid's webhook settings to reduce noise
-
Monitor bounce rates: High bounce rates affect your sender reputation — route bounce events to an alerting system
-
Handle compliance events promptly: Process
spamreport,unsubscribe, andgroup_unsubscribeevents quickly to maintain compliance with email regulations -
Use categories: Add categories to your outgoing emails so webhook events include them for easier filtering and routing
-
Separate environments: Create separate sources for production and staging SendGrid accounts
Troubleshooting
Events Not Arriving
- Verify the Event Webhook is enabled in SendGrid → Settings → Mail Settings
- Check the webhook URL is correct and publicly accessible
- Confirm you have selected at least one event type
- Check SendGrid's Activity Feed for recent email activity
- Ensure you are actually sending emails — the webhook only fires for real sends
Every Event Shows signature_valid: false
- Confirm the source's
providerissendgrid. A source with no provider falls back tocustom, which readsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256— SendGrid sends none of those - Confirm Signature verification is enabled on the Event Webhook — without it SendGrid sends no signature headers at all
- Confirm
signingSecretis the full public key of the webhook that is delivering, copied without line breaks or surrounding whitespace. Each Event Webhook has its own key - Check nothing between SendGrid and Hookbase rewrites the body — the signature covers the raw bytes
Missing Event Types
- Not all event types are enabled by default — check your webhook settings
- Open tracking requires Open Tracking to be enabled in Mail Settings
- Click tracking requires Click Tracking to be enabled in Mail Settings
- Some events only fire for specific email types (e.g.,
group_unsubscriberequires suppression groups)
Duplicate Events
Enable deduplication to drop retried batches:
curl -X PATCH .../sources/11111111-1111-4111-8111-111111110023 \
-d '{"dedupEnabled": true, "dedupStrategy": "auto"}'SendGrid's Event Webhook sends no per-request delivery ID header, so the auto strategy dedups on a
hash of the payload: a byte-identical retry of the same batch inside the dedup window collapses into
one event. Dedup is per request, not per event — to drop individual events that reappear in a
different batch, dedup on sg_event_id in your own handler.
Large Batches Timing Out
SendGrid may send large batches with hundreds of events. If processing takes too long:
- Ensure your destination endpoints respond within the timeout window
- Consider splitting routes to send different event types to different destinations
- Use transforms to reduce payload size before forwarding