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
| What | When |
|---|---|
| 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 good | Older than payloads.delivery_retention_days (90; null = keep) |
| Rotated-out secrets past their grace → cleared | Always |
| Soft-deleted endpoints no delivery points at any more → deleted for good | Deleted 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, endpointsPruneOptions(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-runShow 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 cryptoBy 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.