Verify SendGrid Signed Event Webhooks in Laravel

Verify SendGrid Signed Event Webhook

SendGrid’s Signed Event Webhook ensures the event origin and integrity. This guide shows how to verify the ECDSA signature in Laravel using X-Twilio-Email-Event-Webhook-Signature and the raw request bytes so you only process trusted events. The primary keyword sendgrid signed event webhook verification appears in the examples.

You will implement middleware that verifies the signature, enforces a timestamp window, and only forwards verified events into your queue.

What “Signed Event Webhook” protects

Understand what the signature defends against and why raw bytes matter.

Why you should verify origin + integrity

Signatures confirm the sender and that the payload wasn’t tampered with in transit.

Headers involved (signature + timestamp)

X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp are required for verification.

Raw bytes requirement (no JSON re-encoding)

Verification operates on the timestamp concatenated with the raw body; re-encoding JSON will break verification.

Common gotcha: Verifying after any middleware that mutates the request body will cause false negatives — verify early.

Enable Signed Event Webhook in SendGrid

Turn on Signed Event Webhook in the dashboard and copy the public key to your config.

Where to toggle it + generate keys

Enable the feature under Webhooks → Event Webhook and save the public verification key provided by SendGrid.

Copy/store the public verification key securely

Store the public key in a secure config (environment variable or secrets manager); do not commit it.

Gotcha: “Test integration” only tests signing after save

Remember to save changes before using the dashboard test; otherwise the test will not reflect the new key.

Micro checklist:

  • Save the webhook configuration after uploading the public key.
  • Rotate keys in a maintenance window and validate with Signed Event Webhook tests.
  • Store the public key in config; never log it.

Verification algorithm (SendGrid’s model)

Follow SendGrid’s algorithm precisely: timestamp + raw payload, SHA256, and ECDSA verify using the public key.

Concatenate timestamp + raw payload

The signed payload is timestamp + raw_bytes (no separator).

SHA256 hash

The signature is verified over the SHA256 hash of the signed payload.

Base64 decode DER signature and verify with ECDSA public key

The header carries a base64-encoded DER signature — decode it and verify with OpenSSL.

Suggested diagram: Show timestamp + raw_body → hash → DER sig verification with ECDSA public key.

Laravel implementation options

You can implement verification as middleware (recommended) or validate in controllers if you prefer.

Place the verification middleware early in the pipeline so nothing mutates the raw body before verification.

Controller-first verification (acceptable)

If middleware is not possible, verify at the top of the controller before parsing the body.

Timestamp drift window + replay protection

Apply a small timestamp window (e.g., 5 minutes) and consider short-term replay caches for additional protection.

Micro checklist:

  • Verify timestamp drift (±5 minutes).
  • Reject malformed base64 signatures.
  • Cache recent message IDs to reduce replay risk.

Laravel middleware example (exact)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class VerifySendGridSignedEventWebhook
{
    public function handle(Request $request, Closure $next)
    {
        $signatureB64 = $request->header('X-Twilio-Email-Event-Webhook-Signature');
        $timestamp    = $request->header('X-Twilio-Email-Event-Webhook-Timestamp');

        if (!$signatureB64 || !$timestamp) {
            return response()->json(['error' => 'missing signature headers'], 401);
        }

        // Basic timestamp drift (e.g., 5 minutes)
        if (abs(time() - (int)$timestamp) > 300) {
            return response()->json(['error' => 'stale timestamp'], 401);
        }

        // IMPORTANT: use raw bytes (no JSON decode/encode before verifying)
        $rawBody = $request->getContent();
        $signedPayload = $timestamp . $rawBody;

        $signature = base64_decode($signatureB64, true);
        if ($signature === false) {
            return response()->json(['error' => 'invalid signature encoding'], 401);
        }

        $publicKeyPem = config('services.sendgrid.webhook_public_key_pem');
        $pubKey = openssl_pkey_get_public($publicKeyPem);
        if ($pubKey === false) {
            return response()->json(['error' => 'invalid public key'], 500);
        }

        // OpenSSL will SHA256-hash $signedPayload internally due to OPENSSL_ALGO_SHA256.
        $ok = openssl_verify($signedPayload, $signature, $pubKey, OPENSSL_ALGO_SHA256);

        if ($ok !== 1) {
            return response()->json(['error' => 'signature verification failed'], 401);
        }

        return $next($request);
    }
}

Apply middleware

1
2
3
// routes/api.php
Route::post('/webhooks/sendgrid/events', [SendGridEventWebhookController::class, 'handle'])
    ->middleware(['sendgrid.signed-webhook']);

Now confirm the full chain: send one verified event, then inspect the normalized event in SendPromptly. Open Sample Project · Open Message Log

Forward verified events into SendPromptly

Only enqueue or forward events after signature verification passes; use Idempotency-Key on ingestion requests.

Only enqueue after verification passes

Processing should be gated by verification to avoid spoofed events entering your pipeline.

Idempotency key strategy

Use a stable provider event id as the idempotency key when forwarding to SendPromptly.

Common gotcha: Verifying after queueing means unverified payloads can reach downstream workers — verify first.

Test steps (curl + expected response)

  1. Missing signature headers (should fail)
1
2
3
curl -i -X POST "https://YOUR-APP.test/api/webhooks/sendgrid/events" \
  -H "Content-Type: application/json" \
  -d '[{"email":"[email protected]","timestamp":1513299569,"event":"delivered"}]'

Expected: HTTP/1.1 401 with missing signature headers

  1. Local fixture success (dev-only)
  • Add a tiny PHP script to sign payloads with your own ECDSA keypair, then verify with your matching public key (to validate your code path).
  • Expected: 204 No Content when headers are present and valid.

Common failure modes

  1. Verifying against parsed JSON instead of raw bytes (signature mismatch).
  2. Not including timestamp + payload in the signed content (wrong message).
  3. Ignoring timestamp drift → replay window too large.
  4. Public key formatting issues (missing PEM headers/newlines).
  5. Forgetting to click Save before testing signing (test request won’t validate).

Conclusion

  • Verify SendGrid Signed Event Webhook using the timestamp + raw body and OpenSSL/ECDSA.
  • Place verification early (middleware) and enforce timestamp drift.
  • Only enqueue or forward verified events to SendPromptly.
  • Test locally with your own keypair and confirm runs in Message Log.