renoki-co/laravel-aws-webhooks
Laravel AWS Webhooks helps you receive and process AWS SNS webhook notifications in Laravel. It verifies message signatures, handles subscription confirmations, and routes incoming events to your own handlers for a secure, simple AWS webhook integration.
What it is. renoki-co/laravel-aws-webhooks gives you a Laravel endpoint that receives AWS SNS HTTP(S) deliveries, verifies the SNS message signature, and hands the payload to your application. It is glue, not a framework — the value is the signature verification and subscription handshake you would otherwise hand-roll.
Version 5.2.0 in one line. No new features or API changes; this is a support-matrix release (Laravel 10 support added, PHP 8.2 added, PHP 8.0 and Laravel 8.x dropped). Treat it as a maintenance upgrade, not a feature upgrade.
Minimum steps
composer require renoki-co/laravel-aws-webhooks # resolves to ^5.2 on Laravel 9/10
php artisan vendor:publish --tag="aws-webhooks-config"
Then:
config/aws-webhooks.php — this is the first file to read. It holds the AWS region, credentials source, the expected topic ARN(s), and the route/middleware the package registers.php artisan route:list | grep -i webhook before writing any controller of your own..env (AWS_DEFAULT_REGION, key/secret or an IAM role) — the package needs AWS API access to fetch the SNS signing certificate and to confirm subscriptions.https://your-app.test/... endpoint to a topic, then publish a test message.First real use case. Wire one SNS topic to one queued job:
// app/Providers/EventServiceProvider.php
protected $listen = [
\RenokiCo\LaravelAwsWebhooks\Events\AwsWebhookReceived::class => [
\App\Listeners\HandleAwsWebhook::class,
],
];
class HandleAwsWebhook
{
public function handle($event): void
{
ProcessSnsMessage::dispatch($event->payload)->onQueue('aws');
}
}
Start here rather than writing a controller: the package already did the parsing and verification, and the listener keeps your HTTP path trivially fast.
Verify the exact event class name and config keys against the published config and README for your tag — this package has renamed internals across major versions, and 5.2.0 is not one of the releases that changed them.
Let the package own the route. The most common mistake is reimplementing the endpoint. Use the registered route with the package's validating middleware, and only reach for a custom controller when you need per-topic routing (e.g. /aws/sns/billing vs /aws/sns/ops). If you do go custom, keep the validation middleware attached — never withoutMiddleware() it outside tests.
Parse Message, not the envelope. SNS wraps everything. The outer JSON is transport (Type, MessageId, TopicArn, Signature, SigningCertURL); the actual domain event is a JSON string inside Message. Model it explicitly:
final class SnsEnvelope
{
public static function from(array $payload): self { /* ... */ }
public function inner(): array
{
return json_decode($this->payload['Message'] ?? '{}', true) ?: [];
}
public function isSesBounce(): bool
{
return str_starts_with($this->inner()['notificationType'] ?? '', 'Bounce');
}
}
Then branch in a single handler: SES bounces/complaints, CloudWatch alarm state changes, EventBridge forwarded events, and raw Notification messages all arrive through the same door.
Branch on Type before anything else. SubscriptionConfirmation, UnsubscribeConfirmation, and Notification require different handling. Subscription confirmation is a handshake — the package handles it, but you should log it, because a silently failed confirmation is the #1 reason "the webhook works in staging but not production."
Assume at-least-once delivery. SNS retries on any non-2xx and can duplicate on success. Deduplicate on MessageId:
if (! Cache::add('sns:'.$envelope->messageId, true, now()->addDay())) {
return response()->noContent(); // already processed
}
A unique index on a sns_messages table is the durable version of this; the cache version is fine for low volume but loses the guard on cache:clear.
Acknowledge fast, work later. The HTTP handler should do verification → dedupe check → dispatch → 200. Anything slower than a second or two risks SNS timeouts and duplicate retries. Give the queue job $tries, $backoff, and a failed() that alerts — a failed job is invisible to SNS, which believes delivery succeeded.
Integration tips
routes/api.php, or add the path to VerifyCsrfToken::$except. This bites everyone once.api group by default, set the middleware in config/aws-webhooks.php rather than duplicating routes.Http::fake() for the certificate fetch and post a fixture payload to the route. Add a separate test that asserts a tampered Signature yields a 4xx — that assertion is the entire point of the dependency.5.2.0 is breaking for some consumers. Composer will resolve it automatically on Laravel 9/10, but:
| You are on | Action |
|---|---|
| Laravel 10, PHP 8.1/8.2/8.3 | Safe upgrade, no code changes |
| Laravel 9, PHP 8.1+ | Safe upgrade |
| Laravel 9, PHP 8.0 | Blocked — pin renoki-co/laravel-aws-webhooks:^5.1 until you bump PHP |
| Laravel 8 | Blocked — stay on ^5.1; 5.2.0 will not install |
Check composer.json require.php before running composer update, and confirm your CI matrix actually tests 8.2 if you're claiming support for it. Also confirm your Laravel ^10.0 constraint is real and not ^9.0 with an allow-list.
Signature verification is fussy about the raw body. Verify against the bytes as received. If you log or re-encode the payload before validation, or if a middleware mutates the request body, verification fails with a valid-looking signature. Don't add request-body-rewriting middleware ahead of the endpoint.
Certificate fetching is an SSRF surface. The SigningCertURL is attacker-controlled on a forged request. The package constrains it, but if you extend or replace the validator, enforce: https only, host ends in .amazonaws.com, no redirects, and a short HTTP timeout with a cache. Never proxy through a user-supplied URL.
Clock skew and expiry. Signature validation is time-sensitive; containers with drifted clocks fail intermittently. Keep NTP on, and if you see sporadic 4xx on a payload that replays fine locally, check the timestamp before you check the code.
SNS payloads cap at 256 KB, so the body fits any sane PHP config — but SES bounce payloads can be large enough that logging them verbatim will bloat your log storage. Log MessageId, Type, TopicArn, and a truncated preview; store full payloads in a table or object storage only when debugging.
Debugging workflow, in order:
tail -f storage/logs/laravel.log while publishing a test message from the SNS console.route:list, then the SNS "Delivery status" / CloudWatch NumberOfNotificationsFailed metrics.SigningCertURL host, TopicArn against your allow-list, and the timestamp.php artisan event:list.Extension points worth knowing: the package's events (listen rather than override), the publishable config for ARN allow-listing and route middleware, and the middleware itself — it's the right seam if you need custom topic routing or extra header checks. Prefer listening to events over subclassing the internals; the internals have moved between majors.
Local development. SNS cannot reach localhost. Use a tunnel (ngrok, expose) and re-subscribe the tunnel URL each time it changes — SNS does not follow redirects and stale subscriptions fail silently. For deterministic local tests, skip SNS entirely and post a captured fixture to the route with validation stubbed in a test-only service provider.
How can I help you explore Laravel packages today?