All docs
3 min read

Postmark inbound webhook (platform-level)

Handles bounces, complaints, and inbound parsing sent from Postmark to Formspring. This is a server-wide integration, not per-form. The endpoint lives at POST /webhooks/postmark and is processed by App\Http\Controllers\Webhooks\PostmarkWebhookController.

Distinct from the outbound Postmark driver which sends transactional email per submission.

What it does

  • Bounce events mark recipient addresses as suppressed in email_suppressions so we don't keep sending to dead addresses.
  • Complaint / spam events do the same plus log a flag (a future feature can use this to alert form owners).
  • Inbound parsing (optional) routes inbound email to a form-defined address into a submission - only useful if you've configured an inbound stream.

Step 1 - Configure the inbound webhook URL in Postmark

  1. Sign in to https://account.postmarkapp.com/servers.
  2. Open the Server you use for outbound mail (per postmark.md).
  3. Default Transactional Stream (or whichever stream is sending) → Settings tab → scroll to Webhooks.
  4. Set Bounce webhook: https://formspring.io/webhooks/postmark.
  5. Set Spam complaint webhook: same URL.
  6. Save.

For inbound parsing (optional):

  1. Inbound stream (create one if absent).
  2. Webhook: https://formspring.io/webhooks/postmark.

Step 2 - Configure the inbound secret in Formspring

To prevent unauthorised POSTs, Formspring validates a shared secret in the request. Set in .env:

env
POSTMARK_INBOUND_SECRET=<a long random string>

Then in Postmark, add the same secret to the webhook URL as a query parameter:

text
https://formspring.io/webhooks/postmark?secret=<the same value>

The controller (PostmarkWebhookController) validates request('secret') === config('services.postmark.inbound_secret') and 401s otherwise.

Step 3 - Verify the wiring

  1. In Postmark, send a test bounce: Servers → your server → Activity → click any sent message → Send test bounce → fill in the recipient.
  2. Tail the application logs.
  3. Confirm a row appears in email_suppressions with the bounced address.

Where the credential lives

  • Server: .envPOSTMARK_INBOUND_SECRETconfig/services.php postmark.inbound_secret.
  • Controller: app/Http/Controllers/Webhooks/PostmarkWebhookController.php.
  • Route: routes/web.phpPOST /webhooks/postmark (no CSRF token required since Postmark is the caller).
  • Suppression model: app/Models/EmailSuppression.php + listener app/Listeners/SuppressMailToSuppressedAddresses.php.

Postmark webhook event types we handle

Event type Action
Bounce Add address to email_suppressions with reason.
SpamComplaint Same as bounce + record reason spam.
SubscriptionChange (Currently a no-op - placeholder for future suppression-list-from-Postmark sync.)
Inbound Optional, parses an inbound email into a submission for the configured form.

Security

  • Rotate the inbound secret by generating a new value, updating .env, restarting the app, then updating each Postmark webhook URL's ?secret= query param.
  • The secret is sent as a query string, which means it appears in Postmark's webhook delivery logs. That's acceptable - Postmark restricts log access to your account.
  • For extra defence, add Postmark's IP allowlist to your firewall (Postmark publishes their IPs at https://postmarkapp.com/support/article/800-ips-for-firewalls).

Troubleshooting

Symptom Cause
401 on every Postmark webhook delivery Secret mismatch or query param missing in Postmark URL.
Bounces don't add to suppression list Check the application log for parsing errors. The handler can log + continue silently.
Forms still send to a known-bounced address Suppression listener (SuppressMailToSuppressedAddresses) only fires on MessageSending events. If a driver doesn't go through the framework mailer (Postmark/Resend/SendGrid drivers send via the HTTP client directly), the listener doesn't catch it. Pre-check against the suppression list in those drivers if needed.

Provider docs