Eazip
Eazip.jsPricingSign inStart free
Guides

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_at the 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: false is the right default here. A takeout with one unreadable legacy file should ship with that failure listed in errors, 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. filename is 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

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.