NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Webhooks for Laravel

Payloads and retention

Untrusted input

An inbound payload is data a stranger with the URL — and, for a verified delivery, the secret — chose. It is decoded to arrays only, never objects, depth ≤ 64, big integers as strings, and the package never unserialize()s, eval()s or extract()s anything. In your handler, validate before use, check any value used as a class name, file path, URL or query fragment against an allowlist, and never pass it to unserialize().

The sanitizer

Contracts\PayloadSanitizer::sanitize(array $payload, SanitizeContext $context): array runs on an inbound payload before it is stored, handed to the event and the handler, or rendered. The default, Support\KeyRedactingSanitizer, replaces the value of every key in sanitizer.redact_keys (case-insensitive, at any depth up to 64) with sanitizer.replacement. Swap it with sanitizer.class:

use RoundlyConsulting\Webhooks\Contracts\PayloadSanitizer;
use RoundlyConsulting\Webhooks\DataTransferObjects\SanitizeContext;

final class DropCardData implements PayloadSanitizer
{
    public function sanitize(array $payload, SanitizeContext $context): array
    {
        // $context->direction (Direction::Inbound), $context->endpoint, $context->eventType
        unset($payload['data']['card']);

        return $payload;
    }
}

// config/webhooks.php
'sanitizer' => ['class' => DropCardData::class, /* … */],

A sanitizer that throws refuses the delivery with 500 sanitizer_failed (reported): nothing unsanitized is ever stored, and the delivery id is released, so the sender’s retry is accepted once the sanitizer works — for RFC 9421 the unchanged resend too. Outbound bodies are sent unchanged; they are your own data.

At rest

  • Inbound: the sanitized, decoded payload (never the raw body), payload_bytes and payload_digest (SHA-256 of the raw body), and the headers in inbound.store_headers — signature, credential and cookie headers are never stored, whatever the list says.
  • Outbound: the exact body sent on every attempt.
  • With payloads.encrypt (on) both are encrypted with the app key; each row records how it was written, so switching the setting never makes old rows unreadable.

Retention

WhatWhen
Inbound payloads and outbound bodies of finished deliveries, attempt response excerpts → cleared (payload_pruned_at)Older than payloads.retention_days (30)
Inbound deliveries, finished outbound deliveries and their attempts → deleted for goodOlder than payloads.delivery_retention_days (90; null = keep)
Rotated-out secrets past their grace → clearedAlways
Soft-deleted endpoints no delivery points at any more → deleted for goodDeleted longer than delivery_retention_days ago (null = keep)
$result = Webhooks::prune(new PruneOptions(dryRun: true));   // counts only, changes nothing

$result->total();
$result->inboundPayloads;     // also: outboundBodies, attemptBodies, inboundDeliveries, outboundDeliveries, attempts, expiredSecrets, endpoints

PruneOptions(bool $dryRun = false, ?CarbonInterface $now = null) — now moves the cut-off, for a backfill or a test. The prune runs in chunks and returns a PruneResult with counts per kind. A pruned delivery cannot be replayed or redelivered (PayloadPrunedException). The provider schedules it daily (schedule.prune); from the console:

php artisan webhooks:prune --dry-run

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.