Appearance
Webhooks
Webhooks push delivery events to your application. Instead of polling, you register an HTTPS URL and SendGrail POSTs a signed JSON payload to it whenever something happens to one of your messages — sent, delivered, bounced, marked as spam, failed.
For the full list of event types and their payloads, see the Webhook events reference. This guide covers setting one up and handling it safely.
Register a webhook
- In the dashboard, go to Webhooks → Add webhook.
- Set:
- Endpoint URL — the HTTPS endpoint SendGrail will
POSTevents to. It must be a publicly reachable HTTPS URL with a valid TLS certificate. Loopback, private-network and cloud-metadata addresses are rejected, and SendGrail does not follow redirects. - Select events to listen — search and tick which event types to receive, or choose All events.
- Endpoint URL — the HTTPS endpoint SendGrail will
- Click Add. SendGrail opens the webhook's page, where you copy its signing secret from the header — store it with the same care as an API key.
New webhooks are enabled by default; enable or disable one later from its menu.
The request
Each delivery is an HTTP POST with a JSON body and these headers:
| Header | Example | Purpose |
|---|---|---|
Content-Type | application/json | The body is JSON. |
User-Agent | Sendgrail-Webhook/1.0 | Identifies the sender. |
Sendgrail-Event-Type | email.delivered | The event type, also in the body. |
Sendgrail-Delivery-Id | 42 | Unique per delivery attempt-set — use it for idempotency. |
Sendgrail-Signature | sha256=<hex> | HMAC signature — verify it. |
The body is a standard envelope:
json
{
"type": "email.delivered",
"created_at": "2026-05-21T21:02:38+00:00",
"data": {
"email_id": "a7e2b91c-4d63-4f8a-b0e5-1c9d3f6a2b74",
"from": "Acme <hello@example.com>",
"to": ["recipient@example.org"],
"subject": "Your receipt",
"status": "delivered",
"sent_at": "2026-05-21T21:02:36+00:00"
}
}data.email_id is the same UUID returned by POST /v1/emails — use it to correlate the event with the message you sent.
Verifying the signature
Always verify the signature before trusting a payload. Without it, anyone who learns your URL could post fake events.
The signature is HMAC-SHA256 of the raw request body, keyed with your webhook's signing secret, hex-encoded, and prefixed with sha256=. Compute the same value and compare it in constant time.
Hash the raw body
Hash the exact bytes SendGrail sent — not a re-serialized copy. Parsing the JSON and re-encoding it will change whitespace and key order, and the signature will never match. Read the raw body first, verify, then parse.
js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post(
'/webhooks/sendgrail',
express.raw({ type: 'application/json' }),
(req, res) => {
const received = (req.header('Sendgrail-Signature') || '').replace(/^sha256=/, '');
const expected = crypto
.createHmac('sha256', process.env.SENDGRAIL_WEBHOOK_SECRET)
.update(req.body) // raw Buffer
.digest('hex');
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// handle event.type / event.data
res.sendStatus(200);
},
);python
import hashlib, hmac, os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SENDGRAIL_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/sendgrail")
def sendgrail_webhook():
raw = request.get_data() # raw bytes, before parsing
received = request.headers.get("Sendgrail-Signature", "").removeprefix("sha256=")
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
return "invalid signature", 401
event = request.get_json()
# handle event["type"] / event["data"]
return "", 200php
use Illuminate\Http\Request;
Route::post('/webhooks/sendgrail', function (Request $request) {
$raw = $request->getContent(); // raw body, before parsing
$received = preg_replace('/^sha256=/', '', $request->header('Sendgrail-Signature', ''));
$expected = hash_hmac('sha256', $raw, env('SENDGRAIL_WEBHOOK_SECRET'));
if (! hash_equals($expected, $received)) {
abort(401, 'invalid signature');
}
$event = $request->json()->all();
// handle $event['type'] / $event['data']
return response('', 200);
});bash
POST /webhooks/sendgrail HTTP/1.1
User-Agent: Sendgrail-Webhook/1.0
Content-Type: application/json
Sendgrail-Event-Type: email.delivered
Sendgrail-Delivery-Id: 42
Sendgrail-Signature: sha256=9f86d0818884...
{"type":"email.delivered","created_at":"...","data":{...}}Responding
Return any 2xx status to acknowledge the event. SendGrail treats anything else — or a timeout — as a failure and retries.
- Respond quickly. The delivery request times out after 10 seconds.
- Do the slow work (DB writes, downstream calls) after responding, or on a background queue. Don't make SendGrail wait.
Retries
If a delivery doesn't get a 2xx, SendGrail retries with growing backoff:
attempt 1 → 1 min → 5 min → 30 min → 2 hours → 12 hoursAfter 5 failed attempts the delivery is marked Failed and retries stop. Every attempt — status code, error, count — is visible under Webhooks → View deliveries.
Idempotency
A delivery you already processed can arrive again — your 2xx got lost in transit, your process crashed after handling but before responding, and so on.
Make your handler idempotent. Key it on the Sendgrail-Delivery-Id header, or on the data.email_id + type pair, and skip work you've already done. The rule: receiving the same event twice must never cause a side effect twice.
Managing webhooks
Open a webhook to see its page — the signing secret, the events it listens for, and a delivery log of every event sent to it. From the endpoint's menu you can:
- Edit endpoint — change the URL or the events it listens for.
- Enable / Disable — stop or resume deliveries without deleting the webhook or its history.
- Rotate signing secret — issue a new secret (shown once). Events are signed with it from that point on and the old secret stops working, so update your handler before rotating.
- Delete webhook — removes the webhook and its delivery history; deliveries stop immediately.
Each entry in the delivery log shows its status, HTTP response code and attempt count — click one to inspect the request payload and response body, or Replay it to re-send on demand while debugging timeouts, 5xxs or signature mismatches.
Testing your endpoint
A webhook URL must be a publicly reachable HTTPS endpoint, so test against a deployed environment rather than a local one:
- Register a separate webhook against a staging or pre-production deployment and verify your handler there before enabling one for production.
- Use the delivery log's Replay on a webhook's page to re-send a past event to your endpoint on demand while you iterate on the handler.
- Every attempt records its response status and body, so you can confirm the endpoint verified the signature and returned
2xx.
Next steps
- Webhook events — every event type and its payload.