SendGrid Event Webhook in Laravel (Receive, Queue, Normalize)

SendGrid Event Webhook in Laravel (Receive, Queue, Normalize)

This guide shows how to build a reliable SendGrid event webhook in Laravel: receive batched JSON events, ACK fast, dedupe, normalize, and forward to SendPromptly. You’ll implement an ack-fast endpoint, a queued worker that normalizes events, and an idempotency/dedupe strategy so retries and duplicates don’t create side effects.

This page uses the primary keyword sendgrid event webhook laravel and gives copy‑paste controller and job examples you can run locally.

What you get from SendGrid Event Webhook

SendGrid posts delivery and engagement events as a JSON batch to your endpoint. Understand what you receive so you can design stable consumers.

Event categories (deliverability vs engagement)

Deliverability: delivered, bounce, dropped, deferred. Engagement: open, click, spam report. Map each type to your internal routing and priority.

Payload shape (batched JSON array)

SendGrid sends a JSON array of event objects — parse tolerant and treat each item independently.

Retry behavior (why 2xx matters)

SendGrid retries non-2xx responses; returning 2xx quickly prevents duplicate retries and reduces downstream load.

Micro checklist:

  • Expect a JSON array (batch) in the HTTP body.
  • Return 2xx quickly to stop retries.
  • Persist raw payload for debugging and auditing.

Configure SendGrid

Configure which event categories SendGrid should post and where.

Where to set the Post URL + choose event types

Set the webhook URL in the SendGrid dashboard and select only the events you need.

Use “Test integration” safely in dev/stage

Use the dashboard test to validate your parsing path, but confirm end-to-end in staging with real traffic.

Start with the core deliverability events plus opens/clicks if your product reacts to engagement.

Common gotcha: Test integration can succeed but miss production edge-cases (e.g., very large batches); always validate with real payloads.

Laravel endpoint design

Design the endpoint to be tolerant, fast, and queue work for background processing.

Route + controller structure

Keep the route simple and the controller minimal — parse and enqueue.

Parse batched events safely

Treat each array entry as a separate unit of work and validate required fields before queueing.

Always ACK fast (queue the heavy work)

Do not perform heavy work in the request thread — persist/queue and return 204 or 200 immediately.

Micro checklist:

  • Validate that payload is an array before enqueueing.
  • Log non-fatal parse errors and skip malformed items.
  • Enqueue one job per event for predictable retries.

Required code snippets

A) Route

1
2
3
4
// routes/api.php
use App\Http\Controllers\Webhooks\SendGridEventWebhookController;

Route::post('/webhooks/sendgrid/events', [SendGridEventWebhookController::class, 'handle']);

B) Controller (ACK fast, queue processing)

 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
<?php

namespace App\Http\Controllers\Webhooks;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use App\Jobs\ProcessSendGridEvent;

class SendGridEventWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // SendGrid posts a JSON array of events (batch). Keep parsing tolerant.
        $events = $request->json()->all();

        if (!is_array($events)) {
            Log::warning('SendGrid webhook: non-array payload', ['body' => $request->getContent()]);
            return response()->json(['error' => 'invalid payload'], 400);
        }

        foreach ($events as $event) {
            if (!is_array($event)) continue;
            ProcessSendGridEvent::dispatch($event);
        }

        // Return 2xx quickly so SendGrid won’t keep retrying.
        return response()->noContent(204);
    }
}

C) Job: normalize + forward to SendPromptly

 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
<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\Http;
use Illuminate\Contracts\Queue\ShouldQueue;

class ProcessSendGridEvent implements ShouldQueue
{
    use Queueable;

    public function __construct(public array $event) {}

    public function handle(): void
    {
        $provider = 'sendgrid';
        $eventName = $this->event['event'] ?? 'unknown';
        $sgEventId = $this->event['sg_event_id'] ?? null;
        $ts = (string)($this->event['timestamp'] ?? time());

        // Idempotency: stable per provider event instance
        $idempotencyKey = hash('sha256', implode('|', [$provider, $sgEventId, $eventName, $ts]));

        $canonical = [
            'provider' => $provider,
            'provider_event' => $eventName,
            'occurred_at' => (int)$ts,
            'recipient' => $this->event['email'] ?? null,
            'message_id' => $this->event['smtp-id'] ?? null,
            'meta' => $this->event,
        ];

        Http::withHeaders([
            'Authorization' => 'Bearer '.config('services.sendpromptly.token'),
            'Idempotency-Key' => $idempotencyKey,
        ])->post(config('services.sendpromptly.ingest_url'), [
            'event' => 'email.provider_event',
            'payload' => $canonical,
        ])->throw();
    }
}

Idempotency + dedupe strategy

Design a stable event key and persist dedupe state before applying side effects.

Stable event key using sg_event_id + event name + timestamp

Combine provider IDs with event name/timestamp to form a stable dedupe key for idempotency checks.

Storage options (DB unique index vs Redis SET)

Use a DB unique index for long-term audit or Redis for short TTL dedupe; choose based on retention and replay needs.

Handling out-of-order events

Store occurred_at in your projected state and apply only if the incoming event is newer.

Micro checklist:

  • Use sg_event_id when present; fallback to hashed payload.
  • Persist dedupe key before processing.
  • Emit idempotent_hit logs/metrics on duplicates.

Normalize events for SendPromptly

Map SendGrid fields into a canonical schema before posting to SendPromptly ingestion.

Mapping table (SendGrid → canonical)

Map emailrecipient, sg_event_idprovider_id, timestampoccurred_at, and keep provider meta for debugging.

Post to SendPromptly ingestion with an Idempotency-Key

Always include an Idempotency-Key header when posting to SendPromptly to avoid duplicate ingestion from retries.

Minimal test snippet:

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","sg_event_id":"abc123"}]'

Try it with the Sample Project: Send one test event and confirm it shows up end-to-end. Open Sample Project

Observability

Store raw payloads and correlate provider message IDs to your internal IDs so you can trace retries and failures.

Store raw payload for debugging

Persist the raw JSON in webhook_inbox for replay and post-mortem analysis.

Correlate provider message IDs with your internal message IDs

Log sg_event_id and your internal dedupe key together so Message Log entries can be mapped to app traces.

Message Log workflow in SendPromptly

Use Message Log to inspect per-attempt timing, headers, and the normalized payload that arrived at SendPromptly.

Common gotcha: Not persisting raw payloads makes it hard to reproduce subtle parsing errors seen in Message Log.

Test steps (curl + expected response)

  1. Send a minimal batch
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","sg_event_id":"abc123"}]'

Expected

  • HTTP/1.1 204 No Content
  • A queued job runs and your SendPromptly Message Log shows email.provider_event (or your chosen canonical event name).

Common failure modes

  1. Non-2xx response → retries: SendGrid retries until it gets 2xx (rolling window up to ~24h).
  2. Assuming payload is a single object: SendGrid posts a JSON array (batch).
  3. Slow endpoint / timeouts: processing inline increases retry/duplicate risk; ACK fast and queue.
  4. No dedupe: duplicates happen; build idempotency around provider IDs (sg_event_id).
  5. Blocking IP allowlists: SendGrid IPs change; don’t rely on static IP allowlisting.

Conclusion

  • ACK fast and queue the work; return 2xx to stop SendGrid retries.
  • Persist raw payloads and use sg_event_id for idempotency.
  • Normalize to a canonical schema before forwarding to SendPromptly.
  • Use Message Log + correlation ids to debug deliveries.
  • Test with both dashboard test events and real staging traffic.