The copy you forgot you were keeping
Somewhere in your database there is a table called webhook_events or incoming_deliveries or raw_payloads. It has a body column of type jsonb or text. It has rows going back to the day you shipped the integration. Nobody has ever deleted from it.
That table is a second copy of your provider's data, and you built it by accident. Every customer.updated from Stripe, every orders/create from Shopify, every identity event from your auth provider landed there in full, and stayed. You were storing it to debug delivery. What you actually built is a shadow database of other people's names, emails, addresses, card metadata, and IP addresses. No retention policy. No access controls worth the name. No inventory.
You are not alone here. Almost every team that takes webhooks seriously ends up with this table, because the first rule of debugging webhooks is "keep the raw body," and nobody ever writes down the second rule.
What is actually in there
Open a random row and read it. Not the shape. The contents.
A Stripe charge.succeeded carries the billing name, email, address, and the card's last four and brand. A Shopify order event carries the shipping address, phone number, and line items. An auth provider's user.created carries the email and often the raw profile from whatever social login the user came through. A support tool's webhook carries the entire body of a customer message, which is to say whatever the customer decided to type, which is sometimes a password or a full card number, because customers do that.
This is not metadata. It is personal data under GDPR. It is arguably in scope for CCPA. And if a card number ever lands in it, that row just dragged your logging pipeline into PCI territory. You never decided to become the custodian of any of it. The webhook decided for you, one POST at a time.
Why you started storing them
The reasons are all good ones, which is why this is hard to unwind.
You store the raw body so you can replay a delivery after a bug fix. You store it so that when a customer swears they were never charged, you can prove the event arrived and show exactly what it said. You store it so you can diff two payloads when a provider changes a field shape without telling you, which they do.
None of that is wrong. Storing payloads is fine. Storing them forever is the mistake, undifferentiated, in the same place you keep everything else, with a retention policy of "we never got around to it." Debugging needs the last few days. Disputes might need a few months. Nothing needs a customer.updated from 2023.
The bill comes due three ways
An unbounded payload store costs you nothing right up until it costs you a lot, and the bill arrives from three directions.
First, breach blast radius. When someone gets into that database, and the incident behind our post on rotating webhook secrets after a compromise is a reminder that they do, the size of the disclosure is the size of your retention window. A team that keeps 14 days of payloads reports a small incident. A team that keeps four years reports a catastrophe. The difference was a cron job nobody wrote.
Second, the erasure request. Under GDPR Article 17 a data subject can ask the merchant to delete their data, and that obligation flows down to you as a processor. If a user's personal data is smeared across 900 webhook rows because they were an active customer for two years, "delete this person" becomes a forensic exercise instead of a WHERE clause. You cannot honour a deletion you cannot find.
Third, and quieter, scope creep in your own compliance posture. Auditors ask what personal data you hold and where. "Every webhook we have ever received, in full, forever" is not the answer that makes an audit go faster.
Retention is a decision, so make it
The fix is not clever. It is a number, written down, enforced by something that runs on a schedule.
Pick a retention window per event type, not one global setting. Delivery debugging rarely needs more than a week or two. Financial disputes might justify 90 or 180 days for payment events specifically. Set the window to the shortest period that satisfies a real, named use case, and default everything else to short.
-- runs nightly; the window is per event class, not global
DELETE FROM webhook_events
WHERE received_at < now() - interval '14 days'
AND event_type NOT IN ('charge.succeeded', 'charge.refunded');
DELETE FROM webhook_events
WHERE received_at < now() - interval '180 days'
AND event_type IN ('charge.succeeded', 'charge.refunded');
Forget the exact query. The point is that a payload's right to exist expires, and something enforces that expiry without a human deciding each night. If your store is append-only by design, add a TTL index instead so deletion is the storage engine's job rather than yours.
Metadata outlives the body
Here is the move that resolves most of the tension. The body and the record of the delivery do not have to share a lifetime.
You almost never need last month's payload contents. You very often need to know that the event existed: its id, type, timestamp, source, the HTTP status you returned, how many retries it took, whether it was a duplicate. That metadata is tiny, it is not personal data, and it is the thing you actually reach for when you are reconstructing an incident weeks later. So split them. Keep the delivery record effectively forever. Expire the body fast.
interface DeliveryRecord {
id: string
eventId: string // provider's event id, for dedup and re-fetch
eventType: string
receivedAt: Date
responseStatus: number
attempts: number
duplicate: boolean
payloadRef: string | null // pointer to the body, nulled on expiry
}
When the body expires you null payloadRef and delete the blob. The delivery record survives, so your dashboards, your dedup logic, and your "did this event ever arrive" queries keep working with no gap. What you lose is the ability to inspect the contents of an old event, which is exactly the thing you wanted to lose.
Deletion is becoming a first-class operation
Until recently, deleting payloads from a webhook platform meant deleting the whole delivery record, metadata and all, which is why nobody did it. That is changing, and Svix's September 2026 changelog is a good marker of the shift.
They shipped a bulk expunge API, available from v2.1.0, that deletes the payloads of a specific set of messages in a single call while leaving the delivery metadata intact. That is precisely the split described above, offered as a product primitive. It complements an existing endpoint that wipes the payloads for an entire application at once, which is the "we accidentally sent a card number through, purge it now" button. In the same release the SDK v2 changed the default of with_content to false, so listing messages no longer mirrors every payload back to you unless you ask.
That last change is the tell. When a platform flips a default so payloads are not returned unless requested, it is admitting that the old default, hand you the body every time, was a liability dressed up as a convenience. If you run your own delivery infrastructure, copy the behaviour. Return payloads only when explicitly asked, and make "asked" something you can audit.
Store the body somewhere you can delete it
Retention only works if deletion is cheap, and deletion is cheap only if the body lives somewhere built for it.
Payloads jammed into your primary transactional database are the hardest to expire, because that database is backed up, replicated, and pointed at by half your app. A DELETE there is a write amplified across every replica and every backup you keep. Put the raw body in object storage or a dedicated store with a native lifecycle policy instead, keyed by delivery id, and let the store expire objects on its own schedule. Your transactional database keeps the small metadata record and a reference. Deletion becomes the storage layer's default rather than a query you have to remember to run.
This also gives you a clean encryption boundary. Encrypt the payload store with its own key, and destroying that key becomes another lever. Kill the key and the entire body archive is cryptographically gone, whatever the backups say.
Do not log the body twice
The database table is the copy you know about. The other copies are worse, because you forgot they exist.
Your application logs the payload on error. Your APM captures it as a span attribute. Your error tracker attaches it to the exception. Your load balancer logs request bodies. Suddenly the customer.updated you were careful to expire in 14 days is sitting in three SaaS tools with their own retention that you do not control, and one of them indexes it for full-text search. We went deep on this in why your webhook logs are useless, but the retention angle is sharper. Every place a payload lands is a place you now have to expire it, and the ones outside your database are the ones you cannot DELETE from.
Log the delivery id and the event type. Log the fields you need to trace it. Do not log the body. If you genuinely need a body for a hard bug, capture it on purpose, to the one store that has a lifecycle policy, and never as a side effect of an error handler.
Redact before you persist, not after
If you can strip or tokenize the sensitive fields before the payload ever reaches storage, most of this stops being your problem.
Verify the signature against the raw bytes first. That has to happen against the original body, because reshaping the payload before verification is how you invalidate the signature at the gateway. But once verification passes, nothing says you have to persist what you received. Replace the email with a tokenized reference. Drop the card metadata you never use. Keep the ids you need to re-fetch the real object. What you store is then a skeleton that is useful for debugging and close to worthless to steal.
The catch is that redaction is lossy, and lossy is bad for replay. Which brings us to the tradeoff nobody escapes.
The replay tradeoff you cannot avoid
Every payload you delete or redact is a payload you cannot replay verbatim. That is the whole tension, and pretending otherwise is how teams end up hoarding.
The way out is to stop treating your payload store as the source of truth. It never was. The provider's event log is. Most serious providers keep a queryable event history you can re-fetch from, which is the backstop we argued every consumer needs in the webhook you never got is sitting in an API you forgot to call. If you can re-fetch the current object by id, you do not need to keep the old body to recover. You need to keep the id. This is the same instinct behind fetch-on-notify thin events, where the event is a pointer and not the record.
So the honest position has three parts. A short local window for fast replay of recent failures. A durable metadata record for the long tail. The provider's API as the authority you rehydrate from when something old needs rebuilding. Keep the pointer forever. Keep the body only as long as you can defend, in writing, to whoever eventually asks why you still have it.
Frequently asked questions
How long should I keep raw webhook payloads? Long enough to serve a specific, named use case and no longer. For most teams that means one to two weeks for general delivery debugging, extended to 90 or 180 days only for the narrow set of financial or dispute-relevant event types that justify it. Set the window per event type rather than globally, default everything to the short end, and enforce it with a scheduled job or a storage lifecycle policy so the expiry is not a decision someone has to remember to make each night.
Can I keep the delivery record if I delete the payload body? Yes, and you should. The delivery metadata, meaning the event id, type, timestamp, response status, retry count, and duplicate flag, is tiny, is not personal data, and is what you actually query when reconstructing an incident. Store it separately from the body and give it a long lifetime, then expire the body fast and null its reference. Your dashboards and deduplication keep working; you only lose the ability to read the contents of an old event, which is the part that carried the risk.
Does storing webhook payloads put me in scope for GDPR or PCI? Often, yes. Payloads routinely contain names, emails, addresses, and IP addresses, all of which are personal data under GDPR and make you a processor with an obligation to honour erasure requests. If a card number ever lands in a payload, even by accident because a customer typed one into a free-text field, the store that holds it can be pulled into PCI scope. Short retention, redaction before persistence, and an encrypted payload store with its own key are the controls that keep the exposure small.
What if I redact a payload and later need the original to replay it? Treat your local store as a cache, not the source of truth. Keep the provider's event or object id even after you redact everything else, and rehydrate from the provider's API when you need the real data, since most providers keep a queryable event log for a rolling window. Pair that with a short window of unredacted recent payloads for fast replay of the failures you are actively debugging. You lose verbatim replay of old redacted events, which is a deliberate trade of a rare capability for a permanent cut in what a breach can expose.