Receive SendGrid Event Webhooks reliably
SendGrid’s Event Webhook posts batches of delivery events (delivered, bounce, open, click) to a Railhook source. The source verifies the signature, stores each batch as it arrived and forwards it to your service with retries.
Why SendGrid webhooks need a buffer
Section titled “Why SendGrid webhooks need a buffer”SendGrid wants a 2xx. If it does not get one, it retries at increasing intervals for up to 24 hours after the event, and then stops. (SendGrid: Getting started with the Event Webhook)
These events drive bounce and spam-report handling and suppression lists. An event dropped after 24 hours is a suppression you never applied. With Railhook in front:
- SendGrid is answered as soon as the batch is stored, so nothing enters its retry window because of your app.
- Your service gets up to 5 attempts per forward, with waits from 1 minute to 1 hour, and what still fails lands in Failed Forwards.
- Anything stored can be replayed, days after SendGrid would have given up.
Connect SendGrid
Section titled “Connect SendGrid”You need a Railhook project and an API key. The same steps are available under Incoming in the dashboard.
export RAILHOOK_URL=https://railhook.io # or your own instanceexport RAILHOOK_API_KEY=...export PROJECT_ID=...-
Create the Railhook source to get the URL SendGrid should post to:
Terminal window curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources" \-H "X-API-Key: $RAILHOOK_API_KEY" \-H "Content-Type: application/json" \-d '{"name":"SendGrid","providerType":"SENDGRID","verificationMode":"NONE"}'Keep the
idand theingressUrlfrom the response. -
Create the webhook in SendGrid. Go to Settings → Mail Settings → Event Webhooks and create a new webhook. Put the
ingressUrlin Post URL, and under Actions to be posted select the event types you want. (SendGrid: Event Webhook security features) -
Enable the signature, then save. In Security features, turn on Enable Signed Event Webhook and click Save. The key pair is generated on save, so testing before you save tests an unsigned webhook. Re-open the webhook and copy the verification key it now shows.
-
Give Railhook the key and turn verification on:
Terminal window curl -X PUT "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources/$SOURCE_ID" \-H "X-API-Key: $RAILHOOK_API_KEY" \-H "Content-Type: application/json" \-d '{"name":"SendGrid","verificationMode":"PROVIDER","hmacSecret":"'"$SENDGRID_VERIFICATION_KEY"'"}'Paste it as SendGrid shows it, or wrapped in
-----BEGIN PUBLIC KEY-----; both are read. -
Add a destination, the URL of your own service:
Terminal window curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-sources/$SOURCE_ID/destinations" \-H "X-API-Key: $RAILHOOK_API_KEY" \-H "Content-Type: application/json" \-d '{"url":"https://api.example.com/webhooks/sendgrid","authType":"BEARER","authConfig":"{\"token\":\"...\"}","enabled":true}' -
Test the integration with SendGrid’s Test Your Integration button, after saving. A sample batch appears under Incoming → Received, with one forward per destination.
How the signature is checked
Section titled “How the signature is checked”SendGrid signs the timestamp’s bytes followed immediately by the raw body, with ECDSA over SHA-256, and sends:
X-Twilio-Email-Event-Webhook-Signature: base64 of the DER-encoded signatureX-Twilio-Email-Event-Webhook-Timestamp: seconds since the epoch
Railhook verifies that against the public key. A batch that does not verify is answered 401 and is not stored.
Deduplicate per event, not per request
Section titled “Deduplicate per event, not per request”A SendGrid request is a JSON array of events, each with its own sg_event_id. There is no id for the batch, so Railhook records no provider event id for a SendGrid source and does not deduplicate its requests: keying a batch on its first event would drop the rest of it if SendGrid re-cut the batch on a retry.
Deduplicate in your own handler instead, on sg_event_id per event, as SendGrid documents.
What your service receives
Section titled “What your service receives”The body exactly as SendGrid sent it, byte for byte: the array, not one event per request. Your handler iterates it. The signature headers are not forwarded; your service authenticates Railhook through the destination’s credentials. See Destinations.
Develop locally with the CLI
Section titled “Develop locally with the CLI”-
Install the CLI and log in, as in CLI, then open a tunnel to your app’s port:
Terminal window railhook listen 3000 -
Add a second destination to the source with the printed public URL and your route appended, such as
https://<host>/tunnel/<slug>/webhooks/sendgrid. It reacheshttp://localhost:3000/webhooks/sendgrid. -
Press Test Your Integration in SendGrid, or send yourself a real message, and the batch arrives in your local app. Disable that destination when you stop the tunnel.
Replay after an outage
Section titled “Replay after an outage”Each replayed batch goes to every destination of the source as a new forward:
curl -X POST "$RAILHOOK_URL/api/v1/projects/$PROJECT_ID/incoming-events/bulk-replay" \ -H "X-API-Key: $RAILHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{"sourceId":"'"$SOURCE_ID"'","from":"2026-09-18T10:00:00Z","to":"2026-09-18T14:00:00Z","verified":true}'Replay reaches back as far as events are kept: 7 days on Railhook Cloud, and whatever you configure when self-hosting. Since nothing deduplicates a SendGrid request, a replay re-sends events your handler has seen, another reason to key it on sg_event_id. See Replay.