> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twine.se/llms.txt
> Use this file to discover all available pages before exploring further.

# File Transfers

> Upload a document through the API, and read back the identifier the destination system gave it

A **File Transfer** is the record of one file Twine delivered to a connected system. You upload a
file through the API naming where it should go, Twine delivers it, and the record tells you what
the destination system called the document once it arrived.

That last part is usually the point. A learning platform issuing a certificate does not just need
the PDF filed in the HR system; it needs the HR system's identifier for that document so it can
reference it from the employee's [competence assignment](/platform/data-model/competence). Twine
never links a file to a competence assignment on your behalf — it hands you the identifier and you
supply it where you need it.

<Note>
  **Twine does not keep your files.** An uploaded file is removed shortly after it has been
  delivered. The file transfer record — including the destination's identifier for the document —
  is kept indefinitely, so a transfer from months ago is still readable long after the file is
  gone.
</Note>

## Uploading

Send the file base64 encoded, alongside where it should go:

```json theme={null}
POST /v1/org/file-transfers

{
  "data": {
    "file_name": "first-aid-2026.pdf",
    "content_base64": "JVBERi0xLjcK...",
    "employee_id": "018eae56-8f9d-7ca0-bea3-17f3db6cf75b",
    "domain_mapping_id": "018eae56-8f9d-7ca0-bea3-17f3db6cf75c",
    "idempotency_key": "lms-certificate-99213"
  }
}
```

The response comes back immediately, before the file has been delivered:

```json theme={null}
{
  "data": {
    "id": "018eae57-0000-7ca0-bea3-17f3db6cf75b",
    "status": "pending",
    "remote_id": null,
    "job_id": "018eae57-1111-7ca0-bea3-17f3db6cf75b"
  }
}
```

The decoded file may be at most 5 MB.

### Where the file goes

A file travels through a [file transfer domain mapping](/platform/domain-mappings) that has Twine
on the source side and your destination system on the target side. If your organization has exactly
one such mapping, it is used automatically and you can omit `domain_mapping_id`. Otherwise, name the
one you want: list them with `GET /v1/org/domain-mappings` and pick the one whose `from_domain` is
`file_transfer`.

Naming a mapping that does not exist is rejected with a 422 rather than accepted and left
undelivered, so a mistyped id fails immediately instead of at poll time.

Twine does not check whether the destination can actually accept the file. If something is missing
or unsupported, the transfer fails during delivery and the reason appears on the job — see
[Status](#status) below.

### Employees

`employee_id` is the Twine employee UUID. Twine resolves it to that employee's identifier in the
destination system itself, so you never need to know the remote id.

The field is optional, because not every document belongs to someone. Be aware that destinations
which file documents under an employee will reject one that has none — Hibob, for example, stores
documents per employee and nowhere else. Destinations addressed by path, such as SFTP and S3, take
files with no employee happily.

### Retrying safely

Supply an `idempotency_key` of your own choosing, unique within your organization — something stable
per document, such as your own identifier for the certificate. Repeating a request with a key you
have used before always returns `200` and the original transfer's `id`, never a duplicate record.

What happens next depends on how the first attempt ended:

| Previous status | Repeating the request                                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `delivered`     | Returns the transfer unchanged. The file is already there; it is not uploaded again.                                   |
| `pending`       | Returns the transfer unchanged. Delivery is still in flight.                                                           |
| `failed`        | **Starts a new delivery.** `status` goes back to `pending` — poll the same `id`.                                       |
| `rejected`      | **Starts a new delivery**, re-evaluating conditions, so a condition you have since corrected can let the file through. |
| `undeliverable` | **Starts a new delivery**, in case a file transfer domain mapping has been configured since.                           |
| `unknown`       | Returns the transfer unchanged. The job record is gone, so Twine cannot rule out that the file did arrive.             |

So a retry after a network timeout is safe, and so is a retry after a delivery failure — you never
need a fresh key to get a failed document moving again. A retry re-sends the file you post with it
(Twine does not keep the original bytes), and the transfer record adopts that file's name, size and
checksum.

## Polling for the identifier

Delivery is asynchronous, so the identifier is not available on the response to the upload. Poll
the transfer until it is:

```
GET /v1/org/file-transfers/{id}
```

```json theme={null}
{
  "data": {
    "id": "018eae57-0000-7ca0-bea3-17f3db6cf75b",
    "status": "delivered",
    "remote_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "remote_employee_id": "E-10422",
    "delivered_at": "2026-07-28T09:14:22Z"
  }
}
```

### What the identifier looks like

Destination systems do not agree on how a document is addressed, so a transfer carries three
fields and fills in whichever apply:

* **`remote_id`** — the destination's own identifier for the document. This is what most HR systems
  return and what you will usually want.
* **`remote_path`** — where the file landed, for destinations addressed by path rather than by id
  (SFTP, S3). For these, the path *is* the identity; there is no id.
* **`remote_employee_id`** — the employee's identifier in the destination. Some systems only
  address a document as the pair of this and `remote_id`.

A few systems return nothing identifying at all on upload. Those transfers reach `delivered` with
every remote field null, which means the file arrived but the destination gave nothing to
reference it by.

<Warning>
  **A file that was already there is `delivered` with no `remote_id`.** If a document with the same
  name already exists in the destination and the domain mapping does not have overwrite enabled,
  Twine does not upload the file a second time. The transfer is reported as `delivered` — the
  document you wanted in the destination *is* in the destination — but because no upload happened,
  there is no identifier to report.

  This is indistinguishable from "the destination returns no identifier" unless you look: check
  `remote_metadata.note`, which explains it in plain text on exactly these transfers.

  If you need the identifier back every time, either enable overwrite on the domain mapping, or
  upload under a name you know is unique.
</Warning>

### Status

| Status          | Meaning                                                                                                                                                   |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending`       | Accepted and on its way. Keep polling.                                                                                                                    |
| `delivered`     | The destination accepted the file. The remote fields hold whatever it returned.                                                                           |
| `rejected`      | The file was deliberately not sent: a condition excluded it, or its domain mapping is turned off. Nothing malfunctioned, so there is no error to look up. |
| `failed`        | Delivery was attempted and did not succeed. Look up `job_id` under [Jobs](/platform/other/jobs) for the reason.                                           |
| `undeliverable` | No file transfer domain mapping from Twine matched, so nothing was sent. The upload was recorded but never delivered.                                     |
| `unknown`       | The underlying job record is no longer available. A file transfer outlives its job, so a very old one can end up here.                                    |

### Conditions

Uploads are subject to the same [conditions](/platform/conditions) as any other domain.
Two are checked, and both must pass:

* **File transfer** conditions are evaluated against the uploaded file itself — its `content_type`,
  `size`, `original_name` and so on. This is where a rule like "PDFs only" belongs.
* **Employee** conditions are evaluated against the employee the file belongs to, when you supplied
  an `employee_id`. A file with no employee skips this check rather than failing it.

A file that any condition excludes reaches `rejected`, not `failed` — it was never sent, and nothing
went wrong. If an upload you expected to land keeps coming back `rejected`, check the conditions on
the destination's system integration and on its file transfer domain mapping.

`status` is worked out from the delivery job each time you read the transfer rather than being
stored on it. That keeps it honest, but it also means you cannot filter a list by status. To find
transfers in a particular state, filter [Jobs](/platform/other/jobs) instead, or read `status` off
each transfer.

## Listing

```
GET /v1/org/file-transfers
```

Returns file transfers newest first, with the usual
[cursor pagination and filtering](/api-reference/filtering-and-ordering). You can filter by
`employee_id`, `system_integration_id`, `file_name`, `inserted_at` and `updated_at`.

This lists transfers **Twine performed**. It is not a view of the documents that exist in a
connected system — Twine does not mirror the destination's document library.

## Your own reference data

The optional `metadata` object is returned unchanged on every read of the transfer, which is a
convenient place to keep the identifier your own system knows this document by. It takes a flat
object with string keys and string, number or boolean values, up to 2 KB encoded.

It is also readable from [property mappings](/platform/property-mapping), which is how a value you
send with the upload can influence the delivery itself. Map `metadata` on the File transfer side and
pull a key out of it — filing a document in a folder your own system chooses per upload, rather than
one the integration fixes for every file, is the usual reason to. See
[Hibob](/integrations/hris/hibob#documents) for a worked example.

<Note>
  Two keys are reserved and always describe the transfer itself, so sending them has no effect:
  `source` (always `public_api`) and `content_sha256` (the checksum Twine computed).
</Note>
