The body is an array
SendGrid POSTs `[{...},{...},{...}]`, not a single object. Code written as `body.event` reads `undefined` and silently does nothing. Iterate, always, even when the array has one element.
Email · Free · No signup
Point the SendGrid Event Webhook at a throwaway URL and read what actually arrives: a JSON array of many events in one POST, signed with an elliptic-curve key rather than a shared secret.
No signup · No email · Ready in about a second
At a glance
Cross-checked against SendGrid's own webhook documentation. Providers change these; if something here is stale, tell us.
The payload
Shape-accurate, with obviously fake identifiers. These are the same samples the inspector's Send test webhook panel fires, so you can read one here and then send the identical bytes to your own URL.
A JSON array, not an object. One POST can carry hundreds of events.
[{"email":"buyer@example.com","timestamp":1750000000,"smtp-id":"<fake.0000000000@example.com>","event":"delivered","category":["receipt"],"sg_event_id":"ZmFrZS1kZWxpdmVyZWQtZXZlbnQtaWQ","sg_message_id":"fake-message-id.filterdrecv-0000","response":"250 2.0.0 OK","ip":"198.51.100.10"},{"email":"buyer@example.com","timestamp":1750000042,"event":"open","sg_event_id":"ZmFrZS1vcGVuLWV2ZW50LWlk","sg_message_id":"fake-message-id.filterdrecv-0000","useragent":"Mozilla/5.0","ip":"198.51.100.10"},{"email":"nobody@example.invalid","timestamp":1750000060,"event":"bounce","type":"blocked","status":"5.1.1","reason":"550 5.1.1 The email account does not exist.","sg_event_id":"ZmFrZS1ib3VuY2UtZXZlbnQtaWQ","sg_message_id":"fake-message-id-2.filterdrecv-0000"}]No sample carries a signature header. A valid one can only be produced with your own signing secret, and a fabricated one would make the Signature tab report a mismatch that isn't real. Send a genuine delivery when you want to test verification.
Verification
An ECDSA signature over the timestamp header concatenated directly with the raw body, verified with a public key. Not an HMAC — there is no shared secret, and the value in the dashboard is a public key you cannot sign with.
TryWebhook cannot verify this one. Every built-in verifier here is an HMAC, and SendGrid's signature is asymmetric ECDSA over the NIST P-256 curve — verifying it needs public-key cryptography rather than a keyed hash. Use SendGrid's own `EventWebhook` helper, or your platform's `crypto.verify` with the base64 key from the dashboard. Everything else on this page still works: capture the delivery, read the array, and check the headers arrived.
Events
Not the full catalogue — the handful that carry most integrations. Subscribing to everything is the fastest way to a handler that times out.
| Event | Fires when |
|---|---|
| delivered | The receiving mail server accepted the message. The closest thing to success email offers; it says nothing about the inbox versus the spam folder. |
| bounce | Delivery failed permanently or was blocked. `type` distinguishes `bounce` from `blocked`, and only the first means the address is bad. |
| open | A tracking pixel loaded. Inflated by image proxies and Apple Mail Privacy Protection, and absent for anyone with images off. |
| click | A tracked link was followed. More reliable than `open`, and it carries the `url` that was clicked. |
| spamreport | The recipient pressed the spam button. Suppress the address immediately; continuing to send is how a sending domain gets blocklisted. |
Create a URL above, then go to SendGrid → Settings → Mail Settings → Event Webhooks. Create a webhook, paste the URL, tick the event types you want, and enable Signed Event Webhook Requests — that is what produces the signature headers and reveals the verification key.
SendGrid gives you a Test Your Integration button, which sends one synthetic event of each selected type. That is the fastest way to see the shape. For real events, send yourself a message through the API and then open it and click a link.
[
{ "email": "buyer@example.com", "event": "delivered", "sg_event_id": "...", "smtp-id": "<...>" },
{ "email": "buyer@example.com", "event": "open", "sg_event_id": "..." },
{ "email": "bounce@example.com", "event": "bounce", "type": "blocked", "reason": "..." }
]
Nearly every SendGrid integration bug traces back to this. The handler is written the way handlers for every other provider are written — read body.event, switch on it — and because JavaScript returns undefined for a missing property on an array rather than throwing, it fails without a single error in the logs.
const events = await request.json();
for (const event of events) {
await handle(event); // one at a time, and never let one throw out of the loop
}
return new Response(null, { status: 200 });
The for loop and the unconditional 200 are both load-bearing. SendGrid retries the entire batch on a non-2xx, so one bad row otherwise causes the other 199 events to be delivered again — and again.
You cannot ask for one event per request. High-volume accounts routinely see hundreds of events in a single POST, which is why the timeout is 30 seconds rather than 5. Design the handler to enqueue rather than to process inline, and the batch size stops mattering.
Two headers arrive together:
X-Twilio-Email-Event-Webhook-Signature: MEUCIQ...
X-Twilio-Email-Event-Webhook-Timestamp: 1750000000
The signed data is the timestamp followed immediately by the raw body, with no separator. The signature is DER-encoded ECDSA over P-256, base64-encoded. The dashboard’s “verification key” is a base64 public key.
const signed = timestampHeader + rawBody;
const ok = crypto.verify('sha256', Buffer.from(signed), publicKeyPem, Buffer.from(sigHeader, 'base64'));
This is genuinely different from every HMAC scheme on this site, and it is better: the key on your server cannot forge a signature, so a compromised web server does not let an attacker mint fake events. It also means there is nothing to paste into a signature-verification tool that only speaks HMAC, including this one. SendGrid’s official SDKs each ship an EventWebhook helper that does the four lines above with the DER-to-PEM conversion handled.
Check the timestamp as well — the signature does not expire on its own.
sg_message_id identifies the message, sg_event_id identifies the event. Deduplicate on the second one; grouping by the first is how you assemble a timeline for one send.
bounce needs care. A type of bounce means the address is bad and should be suppressed permanently. A type of blocked means the receiving server refused this message for reputational or content reasons — the address is fine and suppressing it loses a real recipient. reason carries the SMTP response, which is where the actual explanation lives.
Failure modes
SendGrid POSTs `[{...},{...},{...}]`, not a single object. Code written as `body.event` reads `undefined` and silently does nothing. Iterate, always, even when the array has one element.
A single POST can contain a delivery for one recipient, a bounce for another, and three opens, with no ordering guarantee. There is no per-batch subject; the only grouping key is `sg_message_id` on each event.
Return non-2xx because event 47 broke and all 200 events come back. Process each event independently, catch per-event errors, and still return 200 — otherwise one malformed row makes you reprocess everything, repeatedly.
The key is `"smtp-id"`, so it needs bracket access in most languages and cannot be destructured by name. Easy to miss when the surrounding keys are all underscored.
It is a public key, not a secret. It cannot be used to forge signatures and it is safe to commit — but it does mean you cannot verify with an HMAC library, which is where most integrations get stuck.
Answers
Most likely it is receiving an array and looking for an object. The body is always a JSON array of events, even for a single event. The capture above shows the raw bytes so you can see the leading `[`.
No, and the reason is worth knowing: SendGrid signs with ECDSA on the P-256 curve, so verification needs the public key and an elliptic-curve verify operation, not a keyed hash. Every verifier built in here is an HMAC. Use SendGrid's helper library instead.
Build the signed string as the `X-Twilio-Email-Event-Webhook-Timestamp` header followed immediately by the raw body, base64-decode the signature header, and verify both against the base64 DER public key from the dashboard. SendGrid's `EventWebhook` class in each official SDK wraps all of it.
Use `sg_event_id`. It is unique per event and stable across retries, which `sg_message_id` is not — one message produces many events that share it. Store the ids you have handled and skip repeats.
Because opens are measured with a tracking pixel and mail privacy proxies fetch every image before the recipient sees anything. Apple Mail Privacy Protection alone registers an open for messages nobody read. Treat `click` as the real engagement signal.
Other providers
One click
SendGrid → Settings → Mail Settings → Event Webhooks → Create new webhook — paste the URL, trigger an event, and read exactly what arrived.
No signup · No email · Ready in about a second