ankurk91/laravel-ses-webhooks
Laravel package for receiving and handling Amazon SES webhooks. It registers routes for bounce, complaint, and delivery notifications, dispatches events you can listen to, and lets you configure routes and middleware so your app can react to failed, rejected, or undelivered emails.
composer require ankurk91/laravel-ses-webhooks
php artisan vendor:publish --tag=ses-webhooks-config
The config is the whole package surface: route path, middleware, AWS region/credentials, and the SNS topic ARN that is allowed to call the endpoint.SES_WEBHOOK_TOPIC_ARN, AWS region/keys, and the route path). Run php artisan config:clear after any change.https://your-app.test/<configured-route>, and confirm it. The package handles the SubscriptionConfirmation message for you, so the subscription flips to confirmed on the first request.Where to look first: config/ses-webhooks.php, then src/Http/Controllers (route + signature verification) and src/Events (the event classes dispatched per SES notification type). The README is thin and the repo is archived, so the source is the real documentation.
First use case: suppression. Listen for bounce and complaint events and mark the recipient as undeliverable:
Event::listen(SesBounce::class, function ($event) {
User::where('email', $event->email)->update(['email_bounced_at' => now()]);
});
Start with bounces and complaints only; delivery notifications are high-volume and usually not worth persisting.
Fan out from a single entry point. Instead of registering many listeners, listen for the package's generic "notification received" event, persist the raw payload, and dispatch your own domain jobs. This gives you an audit trail and lets you replay events later if a listener had a bug.
Always queue the work. The HTTP endpoint should only verify the signature, store the payload, and return 200 as fast as possible. SNS times out quickly and retries aggressively; slow listeners cause duplicate deliveries and eventual subscription disablement. Use ShouldQueue listeners or dispatch a job from the event.
Deduplicate on the SNS MessageId. SNS is at-least-once. Store the message ID with a unique index before processing, and skip if it already exists. Do not assume ordering.
Store the raw envelope, not just the parsed event. The SNS envelope has Type, MessageId, TopicArn, Timestamp, and a Message field that is itself a JSON string containing the SES notification. Keeping the raw body makes debugging trivial and lets you re-derive anything later.
Restrict by topic ARN. Set the topic ARN in config so a random caller can't POST well-formed SNS payloads to your endpoint. Signature verification prevents forgery, but the ARN check prevents cross-topic confusion when you have staging/production topics.
Multi-environment routing. For local development, expose your app with a tunnel (ngrok/Expose) and subscribe a separate SNS topic in a sandbox AWS account. SES only sends real notifications for real sends, so use SES's mailbox simulator addresses ([email protected], [email protected]) to trigger test events end to end.
Testing. Use Event::fake() and assert the package event is dispatched, but you'll need to bypass SNS signature verification in the test environment—bind a fake validator in the container or POST directly to the controller with the validator mocked. Keep one integration test that exercises real signature verification against a captured payload.
Correlate with your own sends. SES notifications carry the original message ID and any ses:configuration-set/message tags. If you send mail through SES directly, set a configuration set and pass your internal ID as a message tag so the webhook can be traced back to a specific user/notification.
composer.json before upgrading your app, and be prepared to fork or replace it. For a small surface like this, a DIY controller using the AWS SDK's SNS message validator is a realistic alternative.VerifyCsrfToken (or live in the api middleware group). If you see 419s in the SNS delivery logs, this is why.text/plain; charset=UTF-8. Don't add middleware that insists on application/json or parses the body before the package can verify the raw payload—signature verification depends on the exact bytes.Message field. Decode twice; a single json_decode gives you a string, not an object.Type matters. Handle SubscriptionConfirmation and UnsubscribeConfirmation explicitly; only Notification carries an SES event. If your handler assumes every request is a notification, subscription confirmation will throw.sns.*.amazonaws.com or pre-cache certificates, otherwise all requests fail verification.SES_WEBHOOK_TOPIC_ARN changes require php artisan config:clear (or a fresh deploy). A stale cached ARN silently rejects all webhooks.200—unless you want SNS to retry. A 5xx storm can get the subscription disabled.MessageIds against what your app stored. Most "webhooks aren't arriving" issues are an unconfirmed subscription or a URL that isn't publicly reachable over HTTPS.webhook_events table and a replay command are on you—build them on day one.How can I help you explore Laravel packages today?