# Delivering assets

Getting bytes out follows the same rule as getting them in: they do not travel through
the API. You request a signed URL and fetch from storage.

## Download one file

```
GET /files/{id}/download-url
```

Answers with a signed, expiring URL. The optional `size` parameter (`small`, `medium` or
`large`) requests a rendition instead of the original, which is what you want for
anything being displayed rather than downloaded. It applies to images; other file types
return the original whatever you request.

<CodeTabs syncKey="lang">

```bash title="cURL"
curl "https://api.genuineai.app/api/v1/files/<file-id>/download-url?size=medium" \
  -H "X-Api-Key: gai_…" -H "X-Tenant-Id: <workspace-id>"
```

```js title="JavaScript"
// The body is the URL itself: a JSON string, not an object.
const url = await fetch(
  `https://api.genuineai.app/api/v1/files/${fileId}/download-url`,
  { headers },
).then(r => r.json());

const bytes = await fetch(url).then(r => r.arrayBuffer());
```

```python title="Python"
url = requests.get(
    f"https://api.genuineai.app/api/v1/files/{file_id}/download-url",
    headers=headers,
).json()

data = requests.get(url).content
```

</CodeTabs>

<Callout type="note">
The response body is the URL as a bare JSON string, `"https://…"`, not an object
wrapping it. Parse it as a string.
</Callout>

Fetch the signed URL without your API credentials, because the signature is the
authorization. **These URLs last 15 minutes and should not be stored.** Request a fresh
one when you need the bytes: a URL cached in your database is a link that stops working
and, until it does, a credential you did not intend to keep.

For a specific version, `GET /files/{id}/versions/{version_id}/download-url` does the same
for one point in the file's history.

### When you cannot fetch cross-origin

`GET /files/{id}/image-data` streams the bytes through the API instead. It exists for
callers that cannot fetch cross-origin, most often a browser canvas that would be tainted
by the request. Request a rendition rather than the original where one exists, since this
path does carry the bytes through the API.

## Download many files

Two endpoints, differing in how long the work takes.

**`POST /files/actions/download`** packs a small selection and streams the ZIP back in the
response. It suits a handful of files.

**`POST /download-jobs`** hands the work to a background worker and returns a job to poll.
Use it for anything substantial, up to **5,000 files** per job:

<CodeTabs syncKey="lang">

```bash title="cURL"
curl -X POST https://api.genuineai.app/api/v1/download-jobs \
  -H "X-Api-Key: gai_…" -H "X-Tenant-Id: <workspace-id>" \
  -H "Content-Type: application/json" \
  -d '{"file_ids": ["8a1b6e5c-…", "3f2a9c1e-…"], "size": "original"}'
```

```js title="JavaScript"
const job = await fetch(
  "https://api.genuineai.app/api/v1/download-jobs",
  {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ file_ids: fileIds, size: "original" }),
  },
).then(r => r.json());
// { "job_id": "…", "status": "pending", "file_count": 412 }
```

```python title="Python"
job = requests.post(
    "https://api.genuineai.app/api/v1/download-jobs",
    headers=headers,
    json={"file_ids": file_ids, "size": "original"},
).json()
```

</CodeTabs>

Then poll `GET /download-jobs/{id}`:

```json
{
  "job_id": "7b444c0d-…",
  "status": "completed",
  "progress": 100,
  "file_count": 412,
  "size": "original",
  "created_at": "2026-08-10T09:12:44Z",
  "download_url": "https://…",
  "expires_at": "2026-08-11T09:41:02Z"
}
```

`download_url` appears only once `status` is `completed`. A `failed` job carries
`error` instead. `DELETE /download-jobs/{id}` cancels one still running.

<CodeTabs syncKey="lang">

```js title="JavaScript"
async function awaitZip(jobId) {
  for (;;) {
    const job = await fetch(
      `https://api.genuineai.app/api/v1/download-jobs/${jobId}`,
      { headers },
    ).then(r => r.json());

    if (job.status === "completed") return job.download_url;
    if (job.status === "failed") throw new Error(job.error);

    await new Promise(r => setTimeout(r, 3000));
  }
}
```

```python title="Python"
def await_zip(job_id):
    while True:
        job = requests.get(
            f"https://api.genuineai.app/api/v1/download-jobs/{job_id}",
            headers=headers,
        ).json()

        if job["status"] == "completed":
            return job["download_url"]
        if job["status"] == "failed":
            raise RuntimeError(job["error"])

        time.sleep(3)
```

</CodeTabs>

A job belongs to whoever created it: the person who started it is the person who can poll
it.

Files you cannot read are dropped from the selection rather than failing the call, so
`file_count` on the job may be lower than the list you sent. Compare the two if the count
matters.

## Share links

A share link publishes to someone with no account and no key. There are two kinds, chosen
with `share_type`.

### Download links

```bash
curl -X POST https://api.genuineai.app/api/v1/share-links \
  -H "X-Api-Key: gai_…" -H "X-Tenant-Id: <workspace-id>" \
  -H "Content-Type: application/json" \
  -d '{
    "share_type": "download",
    "name": "Q3 press kit",
    "file_ids": ["8a1b6e5c-…"],
    "expires_at": "2026-09-01T00:00:00Z",
    "password": "…"
  }'
```

Up to **500 files** per link. The response includes `kiosk_url`, which is the address to
send the recipient:

```json
{
  "share_id": "…",
  "share_token": "…",
  "share_type": "download",
  "share_file_count": 12,
  "share_expires_at": "2026-09-01T00:00:00Z",
  "share_password_set": true,
  "share_view_count": 0,
  "share_download_count": 0,
  "kiosk_url": "https://share.genuineai.app/public/share/<token>"
}
```

Two defaults are worth knowing: **links expire after 7 days** unless you pass
`expires_at`, and a password is optional. Omit it and anyone with the URL can open the
link. `share_password_set` reports which applies, and the password itself is never
returned.

`share_view_count` and `share_download_count` are maintained for you, so a link doubles as
a lightweight measure of whether what you sent was opened.

### Upload links

`share_type: "upload"` runs the other way: a link that lets someone outside send files
*in* without an account. It uses the same three-step upload described in [uploading
files](/uploading-files), performed against the public surface with a token instead of a
key.

```bash
curl -X POST https://api.genuineai.app/api/v1/share-links \
  -H "X-Api-Key: gai_…" -H "X-Tenant-Id: <workspace-id>" \
  -H "Content-Type: application/json" \
  -d '{
    "share_type": "upload",
    "name": "Event photographer drop",
    "album_ids": ["3f2a9c1e-…"],
    "pre_tags": {"campaign": ["summer-2026"]},
    "max_files": 200,
    "max_size": 5368709120,
    "allow_change_tags": true
  }'
```

| Option | |
|---|---|
| `album_ids` | Albums the arriving files land in. |
| `pre_tags` | Media tags applied on arrival, so contributions are filed correctly without the sender having to do anything. |
| `max_files`, `max_size` | Caps on the drop. |
| `allow_change_tags`, `allow_change_albums` | Whether the sender may change any of the above, or just accept them. |

Files arriving this way enter the library like any other, with the same statuses, the
same processing and the same [AI analysis](/ai-enrichment).

### The public endpoints

The recipient's browser talks to the unauthenticated `/public` surface: `GET
/public/share/{token}` opens a link, `POST /public/share/{token}/verify` checks a
password, and the download endpoints mirror the ones above. Upload links have the
matching set under `/public/upload/{token}`.

You need these only if you are building your own recipient experience instead of sending
people to `kiosk_url`. They are rate-limited more tightly than the rest of the API. See
[rate limits](/rate-limits).

### Manage links

`GET /share-links` lists your own links. `PATCH /share-links/{id}` changes expiry,
password or contents, and `DELETE /share-links/{id}` revokes one immediately. Revoke a
link that has been forwarded somewhere it should not go: a URL cannot be recalled, but it
can be made to stop answering.

## Next steps

- [Uploading files](/uploading-files) is the ingest side of the same flow.
- [Organizing and finding assets](/organizing-assets) is how you select what to deliver.
- [Sharing and access](/sharing-and-access) explains how ACLs decide who sees what.
