WordPress GDPR: When Data Retention Is a Dead Cron Job
There is a setting in our chatbot’s admin screen that says transcripts are deleted after one month. It is set. It has been set for a long time. This morning I checked whether it actually happens, and the honest answer is that the job responsible for it last ran on 9 August and is not scheduled to run again, because the scheduler that would run it is switched off and nothing replaced it.
Nobody had done anything wrong. No error was logged. The settings screen still says one month, and it is still telling the truth about what it was asked to do. It is just not describing what the database contains.
That gap is the subject of this post. If you run a WordPress site with a chatbot, a contact form, a live-chat widget, or anything else that keeps a record of what visitors typed, you have made a data-retention promise somewhere. This is a practical audit of the two ways that promise quietly stops being true, run against a real production install rather than a hypothetical one, with the commands so you can run it against yours.
What the law actually asks for, and what it doesn’t
The obligation people expect to find in the GDPR is a number: keep chat logs for N months. It isn’t there. Article 5(1)(e) — the storage limitation principle — requires that personal data be kept “in a form which permits identification of data subjects for no longer than is necessary for the purposes for which the personal data are processed.” You choose the period and you justify it against your purpose. A support transcript you keep to resolve a ticket has a different natural life than a lead record you keep until the lead converts.
What this means in practice is that the regulation makes your own stated policy the standard you’re held to. That is a more demanding arrangement than a fixed number would be, because it means a retention setting is not paperwork — it is the commitment, and the burden is on you to show the data is actually gone when you said it would be.
Alongside it, Article 17 gives individuals the right to erasure. When someone asks you to delete their data, “we deleted it from the main table” is not a defence if it survives in a second table you forgot about. Breaches of the core principles in Article 5 and of the data-subject rights in Article 17 both sit in the higher penalty tier under Article 83(5) — up to €20,000,000 or 4% of worldwide annual turnover, whichever is higher.
Retention is not the only clock running on a site with a chatbot. The AI Act attaches transparency duties to the chat widget itself, and the Cyber Resilience Act adds vulnerability-reporting deadlines for anyone shipping commercial plugins. They are separate obligations with separate dates, and none of them substitutes for the other.
Two things follow, and they are the two failure modes worth testing:- Does the deletion actually execute? Retention is almost always implemented as a scheduled task. A scheduled task that never fires produces a setting, not a deletion.
- Does the deletion reach everything? Erasure covers the tables somebody remembered to register. Anything unregistered survives a request that reports itself as complete.
Failure mode one: WordPress schedules jobs it may never run
WP-Cron is not a real scheduler. It is a queue that WordPress checks when somebody loads a page. On a quiet site, jobs run late. That is well known, and the standard advice is to turn WP-Cron off and drive it from a real system cron instead:
define('DISABLE_WP_CRON', true);
That advice is correct, and it is also where the trap is. The line above does exactly one thing: it stops page loads from triggering the queue. It does not create the system cron entry that is supposed to take over. Those are two separate steps in two separate places, and only the first one lives in a file you edit while you are thinking about the problem. If the second step is skipped, forgotten, or lost during a host migration, WordPress carries on accepting scheduled jobs and silently never runs any of them.
Here is what that looks like on our install. DISABLE_WP_CRON is true. The crontab for the account contains one empty line. Nothing under /etc/cron* or /var/spool/cron references wp-cron.php. Read at 10:52 UTC on 12 August 2026, 51 of 53 scheduled events were overdue, the oldest by more than four days.
| Scheduled job | Was due (UTC) | Overdue by | What it does |
|---|---|---|---|
mxchat_mcp_oauth_cleanup | 2026-08-08 04:29 | 4.3 days | Expires stale OAuth codes and tokens |
mxchat_forms_wizard_cleanup | 2026-08-08 15:40 | 3.8 days | Clears abandoned guided-form sessions |
mxchat_forms_prune_webhook_log | 2026-08-08 19:59 | 3.6 days | Trims the webhook delivery log |
mxchat_transcript_review_daily | 2026-08-08 22:00 | 3.5 days | Daily transcript review digest |
mxchat_cleanup_old_images | 2026-08-09 01:35 | 3.4 days | Removes generated images past their life |
mxchat_session_store_maintenance | 2026-08-09 01:48 | 3.4 days | Session table maintenance |
mxchat_daily_cleanup | 2026-08-09 01:58 | 3.4 days | General daily housekeeping |
mxchat_cleanup_old_transcripts | 2026-08-09 03:00 | 3.3 days | Applies the transcript retention setting |
The bolded row is the one that matters for this post. The stored setting is mxchat_auto_delete_transcripts: "1month". The mechanism that turns that string into a DELETE is the job above it, and that job has not run since the 9th.
Two events on the whole install were not overdue — an Imagify library recalculation and a Rank Math orphaned-link cleanup, both carrying timestamps in the future. I included them because they are the control that stops this from being a story about a broken reporting tool: the event list is capable of showing future timestamps, so the 51 overdue entries are a real state and not a display artefact. Why those two differ I did not establish, and I am not going to invent a mechanism for it. It is worth noting and worth leaving open.
How to check your own site in one command
With WP-CLI, the read you want is the list:
wp cron event list --fields=hook,next_run_gmt,next_run_relative
Anything showing now in the relative column is overdue. A handful is normal on a quiet site. Fifty is a dead scheduler.
One warning, because this is an easy and expensive mistake: wp cron event run is not the diagnostic counterpart to list. It fires the jobs. On a site with days of backlog that can mean a flood of emails, syncs and deletions executing at once, in an order nobody designed. Use list to look. Only use run when you have decided you want those jobs to happen right now.
Then confirm the other half — that something is actually driving the queue:
grep -r "DISABLE_WP_CRON" wp-config.php
crontab -l | grep wp-cron
If the first command finds true and the second finds nothing, your scheduled tasks are not running. Every retention policy on the site is decorative until that is fixed. The fix is a single crontab line hitting wp-cron.php on an interval — or, better on a busy site, a WP-CLI invocation that skips the HTTP round trip entirely.
Failure mode two: erasure only reaches registered tables
Since 4.9.6, WordPress has shipped a genuinely good privacy framework: Tools → Export Personal Data and Tools → Erase Personal Data, backed by two filters that any plugin can hook to declare “here is the personal data I hold, and here is how to delete it.”
wp_privacy_personal_data_exporters
wp_privacy_personal_data_erasers
This is an opt-in contract. WordPress cannot discover a plugin’s custom tables on its own, so a plugin that never registers a handler is invisible to the erasure tool — and the site owner gets a green “erasure complete” that is true about everything WordPress knows and silent about everything it doesn’t.
I expected to find our own plugin among the offenders. It isn’t. Asking WordPress what is actually registered returns 10 erasers and 11 exporters, and only three sources contribute beyond core:
| Registered by | Erasers | What they cover |
|---|---|---|
| WordPress core | 1 | Comments (plus user and media on the export side) |
| WooCommerce | 6 | Customer data, orders, downloads, payment tokens, shopper lists, email unsubscribes |
| MxChat | 3 | Chat conversations, form submissions, guided wizard responses |
Reading the form-privacy handler was the part that changed my mind about the whole post. Its opening comment explains that it exists precisely because “Tools → Export / Erase Personal Data never visits the two add-on tables,” and the identity matching underneath is careful in a way that is hard to fake: form submissions have no dedicated email column, so it matches candidate rows against the raw JSON payload and then decodes each row to confirm the subject really is the subject before deleting anything. That is someone who understood this exact class of bug and closed it deliberately.
Which makes the gap that remains more interesting, not less.
The one table that is missed, and why it’s the wrong one to miss
The install has 30 chatbot-related tables. Most hold configuration, queues, embeddings or counters — no personal data, nothing to erase. Auditing every column across all 30 for directly identifying fields returns exactly two tables:
| Table | Rows | Identifying columns | Reached by the eraser? |
|---|---|---|---|
mxchat_chat_transcripts | 70 | user_email, user_name, user_identifier | Yes |
mxchat_url_clicks | 857 | user_ip, user_agent | No |
The click log is the one with the deepest history. It holds 857 rows and 558 distinct IP addresses, with the oldest row dated 6 August 2025 — twelve months and six days before I read it. Alongside each IP it stores the user agent, the URL clicked, the surrounding message context and the session ID.
IP addresses are personal data under the GDPR in most circumstances — Recital 30 names online identifiers explicitly, and the Court of Justice settled the dynamic-IP question in Breyer (C-582/14). An IP plus a user agent plus a sequence of clicks plus a session identifier is not an anonymous counter; it is a behavioural record attached to a person, and it is sitting in the one table that a completed erasure request does not touch. Searching both registered privacy handlers for any reference to clicks, IPs or user agents returns zero.
There is a second, sharper detail. The capability to delete these rows already exists in the plugin — the REST API’s session-deletion endpoint documents that it “also deletes related rows in mxchat_transcript_translations and mxchat_url_clicks for each session_id,” and the code does exactly that. So deleting a session through the plugin’s own API cleans up correctly. The WordPress privacy eraser simply doesn’t route through that path.
That is a much better bug than “nobody thought about privacy.” Everybody thought about privacy. Two surfaces were built to delete the same data and only one of them was wired into the tool a regulator would ask about. That is the shape this class of defect usually takes, and it is why “does the plugin support GDPR?” is the wrong question. The useful question is which tables the handler names.
And the retention sweep that does work
One more finding, because it cuts the other way and leaving it out would make this post tidier than the truth. The session store deliberately does not rely on WP-Cron. Its class comment describes “a retention sweep that does not depend on WP-Cron,” it carries a DEFAULT_RETENTION_DAYS of 30, and it trims opportunistically on writes — batches of 200 rows, at most once an hour, with the last sweep timestamp kept in an option. That option reads 11 August, about fourteen hours before I looked.
So on this install one retention path is working precisely because its author assumed the scheduler would fail, and another is stalled because it assumed the scheduler would work. Both assumptions live in the same plugin. The difference is not care; it is which failure the author had been bitten by before.
I also want to flag something I could not explain, rather than paper over it. The transcripts table currently holds 70 rows with the oldest dated 10 August. Two days ago, auditing the same install for a post about model pricing, I measured 488 stored conversations and 3,256 messages. Something removed the older transcripts in the last two days, and it was not the cleanup job, which has not run since the 9th. I don’t know what it was. Naming a plausible mechanism here would be guessing, and a guess in a table of measurements is worse than an admission.
Audit your own install
Four checks, in the order that finds problems fastest. All read-only.
| # | Check | Command | Bad answer |
|---|---|---|---|
| 1 | Is the scheduler alive? | wp cron event list --fields=hook,next_run_relative | Many rows reading now |
| 2 | Is anything driving it? | crontab -l | grep wp-cron | No output, with DISABLE_WP_CRON true |
| 3 | What is registered for erasure? | wp eval 'print_r(array_keys(apply_filters("wp_privacy_personal_data_erasers", array())));' | Your chat/form plugin absent |
| 4 | Where does personal data actually live? | wp db query "SHOW TABLES", then SHOW COLUMNS looking for ip, email, user_agent, name | A table with those columns and no handler |
Check 3 is the one people skip, and it is the cheapest of the four. It asks WordPress itself what is registered rather than trusting a plugin’s marketing page. Check 4 is the one that takes twenty minutes and finds the actual answer, because a table’s name will not tell you whether it holds an IP address. Ours is called url_clicks, which sounds like analytics.
If you want to see this from the visitor’s side as well, our notes on configuring the chatbot cover where transcript retention and contact capture are set.
What we’re doing about it
The click-log gap is filed as a change to the plugin rather than patched quietly on this site, for the same reason we’ve handled previous findings that way: a post that reports a defect and a site that has already fixed it can’t both be accurate, and the fix belongs in the plugin every customer runs, not in one install’s database. The shape of it is small — register the click log with the existing eraser and reuse the session-deletion path that already deletes those rows correctly.
The dead scheduler is a server-configuration fix on our end, and it is the more urgent of the two, because it is not one stalled job. It is every retention policy, every cleanup routine and every scheduled digest on the install, all of them reporting healthy settings and doing nothing.
If you are reading this because you were about to write a retention policy: write the policy, then go and prove the deletion happened. The policy is the easy half.
Frequently asked questions
Does the GDPR say how long I can keep chat transcripts?
No. Article 5(1)(e) requires you to keep personal data no longer than necessary for your stated purpose, and leaves the period to you. That means you define it, document why, and — the part that gets missed — make sure the deletion actually runs. Common defensible choices are 6–12 months for support transcripts and until-conversion-or-6-months for lead capture.
Are IP addresses really personal data?
In most circumstances, yes. Recital 30 of the GDPR names online identifiers directly, and the Court of Justice held in Breyer (C-582/14) that even a dynamic IP can be personal data where the controller has legal means to identify the person. Treat an IP log as in scope unless you have specific advice otherwise.
If WordPress reports the erasure as complete, am I covered?
Only for the data that was registered with the erasure framework. The tool reports on the handlers it was given; it has no way to know about a custom table nobody declared. Run check 3 and 4 above before relying on that confirmation.
Is turning off WP-Cron a mistake?
No — it’s good practice on any site with real traffic, because page-load-triggered cron is unreliable and adds latency. The mistake is doing only half of it. DISABLE_WP_CRON and the system cron entry that replaces it are two separate changes, and the site gives you no warning if the second one is missing.
How would I notice this on my own site?
You wouldn’t, from the admin screens — that’s the point of both failure modes. The retention setting still displays your chosen period and the erasure tool still reports success. Both require you to go and look at the database or the event list. Check 1 takes one command and would have caught our problem four days earlier.
Disclosure: this post was researched and drafted by an AI agent working on the mxchat.ai site, and every figure in it was measured directly against our production install on 12 August 2026 rather than taken from documentation. Maxwell holds editorial responsibility for what gets published here. Where a number could not be explained, the post says so instead of offering a mechanism.