Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Postmark Webhooks Laravel Laravel Package

citricguy/postmark-webhooks-laravel

A no-frills Laravel package that accepts Postmark webhooks and fires a single PostmarkWebhookReceived event for your listeners. No models or migrations, middleware verifies requests come from Postmark, and the webhook URL is configurable. Payload, email, record type, and message ID are available on...

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install the package. Nothing to publish is required for a working setup:

composer require citricguy/postmark-webhooks-laravel

This registers the PostmarkWebhooksServiceProvider, the route (/api/postmark/webhook by default), and the PostmarkWebhookReceived event. There are no migrations, no models, no DB changes — the entire integration surface is one endpoint and one event.

2. Point Postmark at your app. In your Postmark server → settings → webhooks → add webhook, use:

https://<your-domain.com>/api/postmark/webhook

Select the record types you actually care about (Delivery, Bounce, SpamComplaint, Open, Click, SubscriptionChange). Don't select all of them by default — Open and Click fire at very high volume and will dominate your webhook traffic.

3. Create a listener and register it. The listener is where all of your application logic lives.

// app/Providers/AppServiceProvider.php (Laravel 11+/12+/13+)
use Illuminate\Support\Facades\Event;
use Citricguy\PostmarkWebhooks\Events\PostmarkWebhookReceived;
use App\Listeners\HandlePostmarkWebhook;

public function boot(): void
{
    Event::listen(PostmarkWebhookReceived::class, HandlePostmarkWebhook::class);
}
namespace App\Listeners;

use Citricguy\PostmarkWebhooks\Events\PostmarkWebhookReceived;

class HandlePostmarkWebhook
{
    public function handle(PostmarkWebhookReceived $event): void
    {
        // $event->payload      full decoded Postmark payload
        // $event->email        recipient address
        // $event->recordType   Delivery|Bounce|SpamComplaint|Open|Click|SubscriptionChange
        // $event->messageId    Postmark MessageID (your idempotency key)
    }
}

4. First use case: bounce suppression. The highest-value first step is usually hard-bounce handling — it is immediately useful and exercises the full pipeline:

public function handle(PostmarkWebhookReceived $event): void
{
    if ($event->recordType !== 'Bounce') {
        return;
    }

    if (($event->payload['Type'] ?? null) === 'HardBounce') {
        Suppression::firstOrCreate(['email' => $event->email]);
    }
}

Where to look first when something breaks:

  • Verify the route exists — php artisan route:list | grep postmark. If it isn't there, the service provider failed or the config cache is stale.
  • Check the response body — a success returns HTTP 200 with ["success"]. Anything else means the middleware rejected you or your listener threw.
  • Check storage/logs/laravel.log — middleware rejections and listener exceptions land there, but they are easy to miss among normal traffic.

If you need to move the endpoint, publish the config:

php artisan vendor:publish --provider="Citricguy\PostmarkWebhooks\PostmarkWebhooksServiceProvider" --tag="config"

You can then set the path and firewall from .env: POSTMARK_WEBHOOK_PATH, POSTMARK_WEBHOOK_FIREWALL_ENABLED, POSTMARK_WEBHOOK_AUTH_USER, POSTMARK_WEBHOOK_AUTH_PASS.

Implementation Patterns

Dispatch on recordType, not on the event. Since every webhook arrives through the same event, the listener is your router. Keep the entry-point listener thin and delegate:

public function handle(PostmarkWebhookReceived $event): void
{
    match ($event->recordType) {
        'Bounce'         => $this->handleBounce($event),
        'SpamComplaint'  => $this->handleComplaint($event),
        'Delivery'       => $this->handleDelivery($event),
        'SubscriptionChange' => $this->handleUnsubscribe($event),
        default          => null, // Open/Click — usually ignored or batched
    };
}

This keeps the "what record types did I opt into in Postmark?" question answerable in one file.

Queue the listener. The handler runs synchronously inside the HTTP request Postmark is waiting on. A slow bounce handler slows your webhook response and risks a timeout, which triggers Postmark retries. Add implements ShouldQueue to the listener and let the request return 200 in milliseconds. This is the single biggest day-to-day reliability win with this package.

Deduplicate on messageId. Postmark retries non-2xx responses, and your own queue may (rarely) redeliver. If you're doing something non-idempotent — incrementing a counter, sending an internal alert, charging for an email — guard on a messageId that you record yourself:

if (ProcessedWebhook::where('message_id', $event->messageId)->exists()) {
    return;
}

Since the package deliberately ships no model, this table is yours to design. Note that messageId is the email's ID, not a per-webhook ID — for Open/Click with multiple events on one message, it is not unique. Use it as a dedupe key only for record types that fire once per message.

Persist what you need — the package won't. A common pattern is a small email_events table storing message_id, record_type, email, and a JSON column of the raw $event->payload (or a selective subset). You then derive reporting from that. Because $event->payload gives you the raw array, you can key into it for fields the event object does not expose:

$tag  = $event->payload['Tag'] ?? null;
$meta = $event->payload['Metadata'] ?? [];
$stream = $event->payload['MessageStream'] ?? null;

Tag and Metadata are set when you send the email and flow through the webhook — this is the cleanest way to correlate a webhook back to your own domain object (e.g., Tag = "invoice:4821").

Route multiple Postmark streams through one endpoint. The config exposes a single path, so if you run multiple message streams (transactional + broadcast) and need different handling, create separate webhooks in Postmark all pointing at the same URL and branch inside the listener on $event->payload['MessageStream']. Do this rather than trying to register a second route.

Local and CI testing harness. Because the firewall now defaults to enabled in every environment (see below), the standard local workflow is:

# .env.local / .env.testing only — never production
POSTMARK_WEBHOOK_FIREWALL_ENABLED=false

Then hit your own endpoint in a feature test:

$this->postJson('/api/postmark/webhook', [
    'RecordType' => 'Bounce',
    'Email'      => '[email protected]',
    'MessageID'  => 'test-123',
    'Type'       => 'HardBounce',
])->assertOk(); // asserts ["success"]

Use Event::fake() if you only want to assert the event fired, and let a separate unit test cover the listener logic. That split keeps your listener tests fast and your integration test focused on routing and auth.

Basic Auth setup. If you enable Basic Auth, configure the credentials in the Postmark webhook UI and in .env. When Postmark receives a 401 it will retry, so a mismatched password shows up as repeated, spaced-out 401s in laravel.log — a useful signature to recognize.

Gotchas and Tips

v2.3.0 changed firewall behavior in a minor release. Prior to 2.3.0, APP_ENV being anything other than production bypassed an enabled firewall. That escape hatch is gone: POSTMARK_WEBHOOK_FIREWALL_ENABLED now governs authentication in every environment. If you were relying on APP_ENV=staging (or local) to accept synthetic requests, those requests will now be rejected after a composer update on ^2.0. This is the highest-probability source of "it broke and nothing changed" reports. Explicitly set POSTMARK_WEBHOOK_FIREWALL_ENABLED=false in .env.testing and any local env file.

Rebuild your config cache after any env change. php artisan config:cache snapshots your .env; flipping POSTMARK_WEBHOOK_FIREWALL_ENABLED without a rebuild leaves the old value in place and produces extremely confusing behavior (works locally, fails everywhere, or vice versa). If in doubt: php artisan config:clear then test again.

Blank Basic Auth credentials mean no Basic Auth. Setting POSTMARK_WEBHOOK_AUTH_USER/_PASS to empty strings silently skips the auth step rather than failing closed. Audit production .env files for this specifically — an empty pair looks configured in a diff but enforces nothing.

The IP check needs correct trusted-proxy configuration behind a load balancer. The middleware validates the request IP against Postmark's four published webhook IPs. Behind an ALB, Cloudflare, or any reverse proxy, Laravel's Request::ip() returns the proxy's address unless TrustProxies is set correctly — and every legitimate webhook is rejected. Verify this in staging before production; a rejected webhook in production generates retries but no mail-flow visibility.

A throwing listener means a 500, which means Postmark retries. The listener runs inside the request. Any uncaught exception propagates and turns a valid webhook into a failure. Two defenses: make the listener ShouldQueue (so exceptions surface in your queue monitoring, not as retry storms), and wrap risky domain work in try/catch with explicit logging. If you're writing to a database, an outage will trigger retries — plan for that.

There is no built-in rate limiting, and no signature verification. Auth is source-IP (plus optional Basic Auth). Postmark does not sign webhooks, so IP allowlisting is genuinely the strongest primitive available — don't disable the firewall in production. Because the package adds no throttle, high-volume Open/Click streams can flatten a shared app server. Either don't subscribe to those record types, or apply your own throttle middleware to the route.

This package has essentially zero adoption. 0 stars, 0 dependents, single-maintainer project on a ^2.0 line that has already shipped a behavior change in a minor version. That doesn't make it bad — the code surface is tiny and the README is honest about being "no-frills" — but pin the version ("^2.3" and review lock-file diffs), and be prepared to vendor it. The route, middleware, and one event are trivial to replicate in-app if it ever goes stale; the PostmarkWebhookReceived event with payload/email/recordType/messageId is a 30-line class you can own.

The 202 compatibility setting exists but 200 is the default. Successful responses return HTTP 200 with ["success"]. If you've seen 202 in another integration, don't assume it here — and don't "fix" a 200 into a 202 unless you have a reason, since 2xx is all Postmark needs.

You own the persistence layer entirely. There are no models and no migrations, which is the package's stated selling point but also means nothing is recorded unless you record it. Decide up front where raw payloads land — a JSON column on your own table is cheap insurance and lets you backfill analysis later without asking Postmark to replay events (which it will only do within its retry window).

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package