Search by

bernskiold / laravel-postmark

bernskiold

Receive Postmark webhooks as typed Laravel events, and recognise Postmark's inactive-recipient rejections.

Package info

github.com/bernskiold/laravel-postmark

pkg:composer/bernskiold/laravel-postmark

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 2

1.0.0 2026-10-25 19:00 UTC

This package is auto-updated.

Last update: 2026-10-25 19:10:51 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

Laravel sends mail through Postmark. Postmark then sends webhooks back to your application. This package receives these webhooks and changes each one into a Laravel event. You can then use a usual listener for each delivery, bounce, open, click, spam complaint and suppression.

use Bernskiold\LaravelPostmark\Events\EmailBounced;

class FlagBouncedAddress
{
    public function handle(EmailBounced $event): void
    {
        if ($event->isHardBounce()) {
            Subscriber::query()
                ->where('email', $event->recipient())
                ->update(['undeliverable' => true]);
        }
    }
}

What the package does

  • It gives a route macro. Route::postmarkWebhooks('webhooks/postmark') adds the endpoint in the route group that you select.
  • It checks each request against the Basic Auth credentials of the webhook in Postmark. The comparison is safe against timing attacks. If you do not set the credentials, the endpoint refuses all requests.
  • It replies to Postmark immediately. A queued job then dispatches the event. The job tries again with a backoff if it fails.
  • It gives typed methods for the payload, for example isHardBounce(), originalLink() and latLng(). Postmark puts the address in Recipient on some webhooks and in Email on other webhooks. recipient() reads the correct field.
  • It gives the metadata that you attached to the message. Use the metadata to find the record that sent the message.
  • It finds the addresses in a Postmark "inactive recipient" rejection. Postmark sends this rejection when it does not send to a suppressed address.

Installation

Install the package with Composer:

composer require bernskiold/laravel-postmark

Set the credentials of the webhook in Postmark. Then set the same credentials in your environment:

POSTMARK_WEBHOOK_USERNAME=postmark
POSTMARK_WEBHOOK_PASSWORD=a-long-random-string

Add the route. Postmark sends requests from outside your application, so CSRF verification must not apply to the route. Put the route in your api routes, or exclude its path from CSRF verification:

// routes/api.php
Route::postmarkWebhooks('webhooks/postmark');

The macro returns the route. You can add a name or middleware to it.

In Postmark, open the Webhooks settings of your server. Add a webhook with the full URL of the route. Enter the same Basic Auth credentials. Then select the events that you want to receive.

Configuration

To change the queue or the log channel, publish the configuration file:

php artisan vendor:publish --tag=postmark-config
return [
    'webhooks' => [
        'username' => env('POSTMARK_WEBHOOK_USERNAME'),
        'password' => env('POSTMARK_WEBHOOK_PASSWORD'),
        'connection' => env('POSTMARK_WEBHOOK_QUEUE_CONNECTION'), // null: the default connection
        'queue' => env('POSTMARK_WEBHOOK_QUEUE'),                 // null: the default queue
    ],
    'log_channel' => env('POSTMARK_LOG_CHANNEL'),                 // null: the default channel
];

Events

Postmark webhook Event Time from
Bounce Bernskiold\LaravelPostmark\Events\EmailBounced BouncedAt
Delivery Bernskiold\LaravelPostmark\Events\EmailDelivered DeliveredAt
Open Bernskiold\LaravelPostmark\Events\EmailOpened ReceivedAt
Click Bernskiold\LaravelPostmark\Events\EmailClicked ReceivedAt
Spam complaint Bernskiold\LaravelPostmark\Events\EmailComplained BouncedAt
Subscription change Bernskiold\LaravelPostmark\Events\EmailSubscriptionChanged ChangedAt

Listen for these events as for all other Laravel events. All the events extend EmailEvent and have these methods:

$event->recordType();        // RecordType enum
$event->messageId();         // the MessageID that Postmark gave when you sent the message
$event->recipient();         // the address, from Recipient or from Email
$event->timestamp();         // Carbon: the time of the event
$event->messageStream();     // for example "outbound"
$event->tag();
$event->serverId();
$event->from();
$event->subject();

$event->metadata();          // all the metadata that you attached to the message
$event->metadataValue('order_id');
$event->hasMetadata('order_id');

$event->payload;             // the full payload, for fields that have no method
$event->payloadValue('Geo.City');

Bounces and spam complaints

Postmark records a spam complaint as a type of bounce. Thus EmailBounced and EmailComplained have the same methods:

$event->type();              // "HardBounce", "SoftBounce", "SpamComplaint", and other types
$event->typeCode();
$event->name();              // "Hard bounce"
$event->description();
$event->details();
$event->isInactive();        // true if Postmark does not send to this address now
$event->canActivate();
$event->content();           // the full bounce or abuse report, if Postmark includes it

$event->isHardBounce();      // EmailBounced only
$event->isSoftBounce();

Opens and clicks

$event->isFirstOpen();       // EmailOpened
$event->readSeconds();

$event->originalLink();      // EmailClicked
$event->isHtmlClick();
$event->isTextClick();

// EmailOpened and EmailClicked
$event->platform();          // "Desktop", "Mobile", "WebMail" or "Unknown"
$event->deviceType();        // the platform in lowercase
$event->clientName();        // "Chrome 35.0.1916.153"
$event->osName();
$event->userAgent();
$event->ipAddress();
$event->country();
$event->city();
$event->latLng();            // ['lat' => 45.2517, 'lng' => 19.8369]

Subscription changes

Postmark sends a subscription change when it suppresses a recipient on a message stream. It also sends one when it activates the recipient again.

$event->shouldSuppressSending();     // true if suppressed, false if activated again
$event->suppressionReason();         // "HardBounce", "SpamComplaint" or "ManualSuppression"
$event->isHardBounceSuppression();
$event->isSpamComplaintSuppression();
$event->isManualSuppression();

$event->origin();                    // "Recipient", "Customer" or "Admin"
$event->isRecipientInitiated();
$event->isCustomerInitiated();
$event->isAdminInitiated();

Find the message that caused an event

Attach metadata when you send the message. Postmark then includes the metadata in each event for that message.

return (new MailMessage)
    ->subject('Your invoice')
    ->tag('invoice')
    ->metadata('invoice_id', $this->invoice->id);
public function handle(EmailDelivered $event): void
{
    Invoice::find($event->metadataValue('invoice_id'))?->markAsDelivered($event->timestamp());
}

Inactive recipients

Postmark makes an address inactive after a hard bounce, a spam complaint or a manual suppression. Postmark does not send to an inactive address. It returns API error 406, and the send operation throws an exception.

Only the text of the exception identifies this rejection. Postmark sets the code of the exception to zero. Laravel's notification sender also changes the class of the exception. InactiveRecipients examines the text for you:

use Bernskiold\LaravelPostmark\Support\InactiveRecipients;

try {
    $user->notify(new InvoiceReady($invoice));
} catch (Throwable $e) {
    if (! InactiveRecipients::matches($e)) {
        throw $e;
    }

    InactiveRecipients::extract($e)->each(
        fn (string $email) => Subscriber::query()->where('email', $email)->update(['undeliverable' => true])
    );
}

extract() returns a collection of the addresses in the rejection, in lowercase.

Failures

If the RecordType is missing or unknown, the job fails. The package does not ignore the webhook. It writes the full payload to the log, and the job shows in your failed jobs. This package does not process the Inbound webhook, because that webhook has a different payload.

Testing

composer test

Changelog

Refer to the CHANGELOG for the recent changes.

Contributing

Refer to CONTRIBUTING for the procedure.

Security Vulnerabilities

Refer to our security policy for how to report a security vulnerability.

Credits

License

The MIT License (MIT). Refer to the License File for more information.