Diagram of two failure modes for WordPress data retention: a dead scheduler and an erasure handler that misses a table

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.

Two failure modes for WordPress data retention: a dead scheduler and an erasure handler that misses a table
The two failure modes, measured on one production install. Neither raises an error.

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:

  1. 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.
  2. 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.

Bar chart of eleven overdue chatbot scheduled jobs, transcript cleanup 3.3 days late
Every chatbot job on the install was overdue. The transcript retention job is the bolded one.

Scheduled jobWas due (UTC)Overdue byWhat it does
mxchat_mcp_oauth_cleanup2026-08-08 04:294.3 daysExpires stale OAuth codes and tokens
mxchat_forms_wizard_cleanup2026-08-08 15:403.8 daysClears abandoned guided-form sessions
mxchat_forms_prune_webhook_log2026-08-08 19:593.6 daysTrims the webhook delivery log
mxchat_transcript_review_daily2026-08-08 22:003.5 daysDaily transcript review digest
mxchat_cleanup_old_images2026-08-09 01:353.4 daysRemoves generated images past their life
mxchat_session_store_maintenance2026-08-09 01:483.4 daysSession table maintenance
mxchat_daily_cleanup2026-08-09 01:583.4 daysGeneral daily housekeeping
mxchat_cleanup_old_transcripts2026-08-09 03:003.3 daysApplies 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 byErasersWhat they cover
WordPress core1Comments (plus user and media on the export side)
WooCommerce6Customer data, orders, downloads, payment tokens, shopper lists, email unsubscribes
MxChat3Chat 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:

TableRowsIdentifying columnsReached by the eraser?
mxchat_chat_transcripts70user_email, user_name, user_identifierYes
mxchat_url_clicks857user_ip, user_agentNo

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.

Bar chart of 857 click-log rows and 558 distinct IP addresses retained across twelve months
The click log holds twelve months of IP addresses. June and July 2026 recorded no rows.

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.

#CheckCommandBad answer
1Is the scheduler alive?wp cron event list --fields=hook,next_run_relativeMany rows reading now
2Is anything driving it?crontab -l | grep wp-cronNo output, with DISABLE_WP_CRON true
3What is registered for erasure?wp eval 'print_r(array_keys(apply_filters("wp_privacy_personal_data_erasers", array())));'Your chat/form plugin absent
4Where does personal data actually live?wp db query "SHOW TABLES", then SHOW COLUMNS looking for ip, email, user_agent, nameA 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.

Similar Posts