Skip to content

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

  1. In the dashboard, go to Webhooks → Add webhook.
  2. Set:
    • Endpoint URL — the HTTPS endpoint SendGrail will POST events 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.
  3. 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:

HeaderExamplePurpose
Content-Typeapplication/jsonThe body is JSON.
User-AgentSendgrail-Webhook/1.0Identifies the sender.
Sendgrail-Event-Typeemail.deliveredThe event type, also in the body.
Sendgrail-Delivery-Id42Unique per delivery attempt-set — use it for idempotency.
Sendgrail-Signaturesha256=<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 "", 200
php
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 hours

After 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

Transactional email on your own domain.