Templates / Data retention purge
Enforcing a retention policy
A retention promise in a privacy policy is only true if something enforces it. This is that something, and its run history is the evidence you will be asked for.
The recipe
- Request
- POST https://{{domain}}/api/admin/retention/apply
- Schedule
- At 01:00 AM (0 1 * * *)
- Headers
- Authorization: Bearer {{secret}}encrypted
- Timeout
- 120s
- Attempts
- 3
- Counts as success
- 2xx
You supply
- Your domain
- example.com
Host only — no https:// and no trailing slash.
- Shared secret
- s3cr3t-valuestored encrypted
Whatever your endpoint checks the caller against.
Why this job exists
Retention periods get written during a compliance exercise — ninety days for logs, two years after account closure, whatever the policy says — and then nothing is built to apply them. The document is published, the audit passes, and the data stays forever.
That is a live problem rather than a theoretical one. Under the GDPR, storage limitation is a principle rather than a suggestion, and holding personal data past your own stated period is a breach of the policy you published. It also makes every subsequent incident worse: a breach discloses ten years of records instead of two, and the difference is entirely self-inflicted.
There is a second reason to schedule it rather than script it. An auditor asking how you enforce retention is asking for evidence, and "here is a job that has run nightly for eighteen months, here is its history" is a far better answer than a script somebody occasionally runs.
Setting it up
Write the endpoint so the policy is data, not code scattered across functions. One table of rules is auditable; a dozen bespoke delete statements are not:
The policy, in one place
const POLICY = [
{ table: 'audit_logs', column: 'created_at', days: 365 },
{ table: 'analytics_events', column: 'occurred_at', days: 90 },
{ table: 'closed_accounts', column: 'closed_at', days: 730 },
];
const applied = [];
for (const rule of POLICY) {
const deleted = await purge(rule);
applied.push({ ...rule, deleted });
}
return Response.json({ applied });Returning what each rule removed turns every run into a record of what was deleted and when — which is the artefact that makes this defensible rather than merely done.
Checking it actually works
- Reconcile the rules in code against the periods in your published privacy notice. They drift, and the notice is the one you are held to.
- Confirm deletions cascade to related tables, backups excepted, and that soft-deleted rows are eventually hard-deleted — a deleted_at flag is not deletion.
- Keep the run history. It is the evidence, and it is worth checking it is retained longer than the policy it enforces.
Where it goes wrong
Deleting something you are legally required to keep
Tax and accounting records typically have a statutory minimum of six or seven years, and it overrides your preference to delete. Invoices, payment records and some correspondence must be excluded. Get the rules reviewed before the job runs, not after.
Data under legal hold
Anything subject to litigation or a regulatory request must be preserved regardless of policy. If holds are possible in your business, the purge has to check for them.
Backups outlive the purge
Deleting from the live database while keeping seven years of backups means you still hold the data. Backup retention has to be part of the same policy, and it is the part most often missed.
Questions
Is this enough for GDPR compliance?
It covers storage limitation, one requirement among several. Access, rectification and erasure requests are separate work, and this is not legal advice — have the periods reviewed by someone qualified.
Should it be a hard delete or an anonymisation?
Anonymising is often better: you keep aggregate analytics and stop holding personal data. It only counts if it is irreversible — a hashed identifier you can still join on is pseudonymisation, and it is still personal data.
What if a run deletes the wrong thing?
There is no undo, which is why the rules belong in one reviewable place and why you should run each new rule as a SELECT first. Log counts per rule so an anomaly is visible the next morning.
Running it here
This is a preset in the product, not an illustration. Pick it in the dashboard, fill in the 2 values above, and the job is created, scheduled and enabled — with retries, a record of every run showing status, latency and response, and an alert the first time one fails. Secret values are encrypted at rest and never rendered back.
The free plan runs 3 jobs hourly and needs no card. Where a recipe wants a finer cadence than your plan allows, it is slowed to the fastest schedule you are permitted rather than rejected.