Download Export
Once status is COMPLETED, fetch a time-limited signed URL for the export archive. The URL is short-lived — render it into an `<a download>` and trigger immediately.
GET /api/v1/gdpr/export/:id/download — 🔑 Bearer
Returns a time-limited signed URL for the completed export archive. Only succeeds when the request is COMPLETED; intermediate states return 404 export_not_ready. The URL is short-lived (expires at DataExport.expiresAt, falling back to +24h when no expiry is recorded).
The signed URL is single-shot in spirit. Don't email it, don't paste it in chat — anyone with the URL can download until it expires. Render into an <a href={downloadUrl} download> and trigger the click immediately, then discard from memory.
Returning a URL (rather than streaming the bytes) keeps the API server out of the data path — the file is served directly by object storage. The signing model is owned by the upload service; the URL works without further auth headers until expiresAt.
Request
Path parameters
| Param | Type | Validation | Notes |
|---|---|---|---|
id | string (UUID) | ParseUUIDPipe | The id returned by POST /gdpr/export |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK — ApiResponseOf<GdprExportDownloadDto>
{
"success": true,
"data": {
"downloadUrl": "https://cdn.bio.re/exports/uuid/export.zip?sig=...",
"expiresAt": "2026-04-30T20:04:32.000Z"
}
}| Field | Type | Notes |
|---|---|---|
downloadUrl | string | Time-limited URL to the export archive — render directly into <a href download> |
expiresAt | string (ISO 8601) | When the URL stops working — pulled from DataExport.expiresAt, fallback now + 24h |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | error.gdpr.not_export | The GDPRRequest exists but is type DELETION, not EXPORT |
401 | (guard) | Missing / invalid bearer token |
403 | error.gdpr.not_owner | Request exists but userId does not match |
404 | error.gdpr.request_not_found | No GDPRRequest row with this id |
404 | error.gdpr.export_not_ready | Status is PENDING / PROCESSING / FAILED / CANCELLED (not COMPLETED) |
404 | error.gdpr.export_file_missing | Status is COMPLETED but no matching DataExport row with fileUrl was found |
Side effects
- Lookup
GDPRRequest; throwrequest_not_foundif missing. - Ownership check (
request.userId === jwt.sub) → elsenot_owner. - Type check (
request.type === EXPORT) → elsenot_export. - Status check (
request.status === COMPLETED) → elseexport_not_ready. - Find the user's most recent
DataExportwithstatus = COMPLETEDANDfileUrl != null(orderBy: createdAt desc). If none →export_file_missing. - Hand the file URL to the upload service, which produces a signed, time-limited URL suitable for direct browser download.
- Compute
expiresAt = DataExport.expiresAt ?? (now + 24h). - Return
{ downloadUrl, expiresAt }. No mutations.
Code samples
curl https://api.bio.re/api/v1/gdpr/export/a1b2c3d4-e5f6-7890-abcd-ef1234567890/download \
-H "Authorization: Bearer $ACCESS_TOKEN"type GdprExportDownload = {
downloadUrl: string;
expiresAt: string;
};
async function getExportDownload(accessToken: string, id: string): Promise<GdprExportDownload> {
const res = await fetch(`https://api.bio.re/api/v1/gdpr/export/${id}/download`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Download URL fetch failed'), {
code: json?.error?.code,
});
}
return json.data;
}
// Trigger the download immediately so the URL doesn't linger in memory
function triggerDownload(downloadUrl: string) {
const a = document.createElement('a');
a.href = downloadUrl;
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}import { useMutation } from '@tanstack/react-query';
export function useGetExportDownload() {
// Mutation — not a query — because it produces a side-effect (triggers download)
// and we never want to cache the signed URL.
return useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/v1/gdpr/export/${id}/download`);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Download URL fetch failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as GdprExportDownload;
},
});
}Try it
Authorization
bearer In: header
Path Parameters
Response Body
application/json
application/json
application/json
application/json
curl -X GET "https://loading/api/v1/gdpr/export/string/download"{
"success": true,
"data": {
"downloadUrl": "https://cdn.bio.re/exports/uuid/export.zip?sig=...",
"expiresAt": "2019-08-24T14:15:22Z"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/user/gdpr.controller.ts | 57–68 (downloadExport) |
| DTO (response) | apps/api-core/src/modules/user/dto/gdpr-response.dto.ts | 41–47 (GdprExportDownloadDto) |
| Service | apps/api-core/src/modules/user/user.service.ts | 603–631 (gdprDownloadExport) |
| URL signing | apps/api-core/src/modules/upload/upload.service.ts | getSignedDownloadUrl() |
| Prisma models | packages/prisma/prisma/schema.prisma | GDPRRequest, DataExport.fileUrl, DataExport.expiresAt |
Get Export Status
Poll the status of a previously created GDPR export request. Returns the current state plus completedAt when finished.
Request Export (Legacy)
Older /users/export alias. Same intent as POST /gdpr/export but with a simpler response shape and a slightly looser duplicate-check. Prefer the GDPR endpoint for new clients.