Build a User Data Export (GDPR Takeout) as a ZIP
Implement "download my data" as an async job — gather records and files, package them into one expiring ZIP, and email the link. The pattern GDPR Article 20 requests and account-deletion flows both need.
"Give the user their data" shows up on every product's roadmap eventually — as a GDPR data-portability request, an account-closure flow, or just a trust feature. The deliverable is always the same shape: one archive containing the user's records and files, handed over securely and then gone.
What makes takeout different from other ZIP features:
- The content is assembled, not just collected. Part of the export is files the user uploaded; part is records you generate at export time (profile, activity, settings as JSON or CSV).
- It must run async. Gathering a heavy account takes minutes; nobody holds a request open for it. The standard UX is "we'll email you when it's ready".
- The artifact is sensitive. It aggregates everything you know about one person, so the link must expire and the flow should leave an audit trail.
The flow
Generate → upload → sign → job → email. The generated records become objects in your bucket first, so the ZIP job treats them exactly like the user's uploaded files:
export async function startUserExport(userId: string) {
// 1. Generate the record dumps and stage them next to the user's files
const profile = await exportProfileJson(userId);
await bucket.put(`exports/${userId}/profile.json`, profile);
const activity = await exportActivityCsv(userId);
await bucket.put(`exports/${userId}/activity.csv`, activity);
// 2. Sign the staged records + the user's own uploads
const files = [
...(await listPrefixAsSignedUrls('app-data', `exports/${userId}/`)),
...(await listPrefixAsSignedUrls('app-data', `uploads/${userId}/`)),
];
// 3. One job per export request
const response = await fetch('https://api.eazip.io/jobs', {
method: 'POST',
headers: {
'X-API-Key': process.env.EAZIP_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files,
zip_filename: 'your-data-export.zip',
expires_in: 7 * 86400,
fail_on_url_error: false,
metadata: { user_id: userId, kind: 'gdpr_export' },
}),
});
const { job_id } = await response.json();
await recordExportRequested(userId, job_id); // audit trail
return job_id;
}The job.completed webhook carries your metadata back — that is the
moment to email the user their link and log delivery. Flow details and
payloads are in
Create a ZIP from URLs with One API Call.
Decisions that matter for takeout
- Give the link a deliberate lifetime. Seven days is a common choice:
long enough for the user to act on the email, short enough that a
forgotten link in an inbox isn't a standing copy of their data. After
expires_atthe archive is deleted — which is exactly the behavior you want to describe in a privacy policy. - Record the trail in
metadata.user_id, request kind, and your ticket ID come back on every webhook and job fetch, so "when did we deliver the export for request X" is answerable from your own logs. fail_on_url_error: falseis the right default here. A takeout with one unreadable legacy file should ship with that failure listed inerrors, not block the whole legal deadline.- Clean up the staged records (
exports/{userId}/…) after the webhook arrives; the archive no longer needs them. - Structure the archive for a human.
filenameis yours per entry —profile/profile.json,files/…reads better in the unzipped folder than raw storage keys.
When you don't need this
If accounts are tiny — a JSON dump and a handful of files — generating the archive synchronously in your own endpoint is fine, and simpler. The job pattern earns its keep when accounts can be heavy, when you want the email-when-ready UX, or when you'd rather not build expiring delivery infrastructure for a feature used a few times a month.
This page covers the delivery mechanics, not the legal scope — what data belongs in a GDPR Article 20 export is between you and your counsel.
FAQ
Is the download link safe to email?
It is a signed, expiring URL: anyone with the link can download until
expires_at, which is why the lifetime should be short and the email
should go to the account's verified address. For higher-sensitivity flows,
put the link behind your own authenticated page instead of in the mail
body.
How do I include database records, not just files?
Write them to your bucket as generated files first (JSON, CSV), then sign them like everything else. The staging step is what turns "records" into "objects a ZIP can contain".
What if the user requests another export while one is running?
Create a new job; each is independent. Rate-limit on your side if abuse is a concern — one export per account per day covers most policies.
Does this satisfy GDPR's "machine-readable format" requirement?
JSON and CSV inside a ZIP is the format most large providers ship for Article 20 requests. The regulation's interpretation is your counsel's call; the delivery mechanics here don't constrain it.
Add "Download All" to a Client Portal
Give portal users one button that turns their documents into a ZIP — authorized per client, built as a background job, and delivered as an expiring link that survives the page.
Bundle Generated Reports and Invoices into a ZIP
Turn a month of generated PDFs — invoices, statements, reports — into one ZIP download link on a schedule, with no browser involved and no ZIP code in your cron job.