# The transfer protocol

> Four HTTP routes between a s3nd server and anything that talks to it — written down, so it is not just whatever the handler happens to do.

Canonical: https://doc.s3nd.sh/docs/protocol · Markdown: https://doc.s3nd.sh/docs/protocol.md

A browser cannot hold your S3 credentials, so a transfer always has a server in the middle. The
shape of that middle is this protocol: four routes, one error format.

It is written down rather than left implicit for three reasons. A client works against any server
that answers these routes, not only against `createTransferHandler()`. A server written in Go or
Rails works with every s3nd client. And the CLI pointed at `--remote` cannot tell which of
those it is talking to — which is exactly why `s3nd put` works against your own deployment.

## Serving it

```ts title="app/api/transfers/[[...route]]/route.ts"
import { createBucket, createTransferHandler } from '@s3nd/core'

export const { GET, POST, DELETE } = createTransferHandler({
  bucket: createBucket({ bucket: 'my-bucket' }),
  app: 'notes',
  expiresIn: 3600,
})
```

The handler takes a `Request` and returns a `Response` — nothing else. That is a Next route
handler, a Hono route, `Bun.serve`, Deno, or a worker, without an adapter for any of them.
[Setting up a server](https://doc.s3nd.sh/docs/server) configures the rest of it: credentials, authorization, expiry,
request limits and CORS.

<Callout type="warn">
  Every route is public unless you pass `authorize`. For a personal drop box behind a proxy that is what you want; for
  anything else it is not.
</Callout>

```ts
createTransferHandler({
  bucket,
  authorize: async (request) => request.headers.get('authorization') === `Bearer ${process.env.TOKEN}`,
})
```

Return `false` for a plain 401, or a `Response` to answer with your own.

## Talking to it

```ts
import { createTransferClient } from '@s3nd/protocol'

const transfers = createTransferClient({ baseUrl: 'https://drop.example.com/api/transfers' })

const { code } = await transfers.createSnapshot({ data: state, version: 3 })
const incoming = await transfers.read(typedCode) // null when unknown or expired
```

`@s3nd/protocol` is `fetch` and nothing else. Its whole dependency tree is nanoid, and no
path from it reaches the AWS SDK — so it bundles for a browser, a worker or React Native without
dragging a storage client along. That is the reason it is a separate package rather than a subpath
of `s3nd`: a subpath would have put the SDK in every front-end's `node_modules`.

In React you rarely call it directly — [the hooks](https://doc.s3nd.sh/docs/react) wrap it.

## The routes

All four are relative to wherever you mounted the handler, `/api/transfers` by default.

### `POST /`

Creates a transfer and returns the code. What you send decides what it holds.

With `Content-Type: application/json`, the body is `{ data, version?, device? }` and the transfer
is a **snapshot** — structured state, handed back inline on read.

With any other content type, the body is the raw bytes and the transfer is a **file**. The original
filename travels percent-encoded in `X-S3nd-Filename`.

```json title="201"
{ "code": "K7QP2M4X", "kind": "snapshot", "createdAt": "…", "expiresAt": "…", "size": 412 }
```

The server picks the code, never the client — it retries a fresh one on the rare collision rather
than overwriting a live transfer.

### `GET /:code`

Everything known about a transfer. For `kind: "snapshot"` the state comes back inline as `data`;
for `kind: "file"` it does not, and you fetch `/raw` instead.

```json title="200"
{
  "code": "K7QP2M4X",
  "kind": "snapshot",
  "createdAt": "…",
  "expiresAt": "…",
  "device": "Pixel 8",
  "app": "notes",
  "version": 3,
  "data": { "notes": [] }
}
```

The code is normalized before lookup, so whatever the user typed — lowercase, grouped with spaces
or dashes, `O` where they meant `0` — resolves to the same transfer. See
[sync codes](https://doc.s3nd.sh/docs/sync-codes).

### `GET /:code/raw`

The bytes, with the stored `Content-Type` and a `Content-Disposition` carrying the filename. With
`raw: 'redirect'` the handler answers `302` with a presigned URL instead, so the payload never
transits your server twice.

### `DELETE /:code`

Burns a code, and answers `204`. Deleting one that is already gone is not an error — a client that
burns a code after a successful restore should not have to care whether it won that race.

## Errors

Every non-2xx answer carries the same body:

```json
{ "error": { "code": "NOT_FOUND", "message": "Unknown or expired code." } }
```

| `code`              | HTTP  | What happened                                        |
| ------------------- | ----- | ---------------------------------------------------- |
| `INVALID_REQUEST`   | `400` | Malformed body, or a method the route does not serve |
| `INVALID_SYNC_CODE` | `400` | What was typed cannot be a code in this alphabet     |
| `UNAUTHORIZED`      | `401` | `authorize` refused                                  |
| `NOT_FOUND`         | `404` | No such code — or it expired                         |
| `CODE_TAKEN`        | `409` | No free code found; vanishingly unlikely at 40 bits  |
| `SNAPSHOT_TOO_NEW`  | `409` | Written by a newer schema than this deployment reads |
| `TOO_LARGE`         | `413` | Over the configured `maxSize`                        |
| `INTERNAL`          | `500` | Anything else                                        |

`NOT_FOUND` deliberately covers expiry as well as absence. Distinguishing them would let someone
probe which codes have been used.

Clients throw `TransferError`, which carries that `code` — branch on it rather than on status codes
or message text:

```ts
import { isTransferError } from '@s3nd/protocol'

try {
  await transfers.createSnapshot({ data: state })
} catch (error) {
  if (isTransferError(error) && error.code === 'TOO_LARGE') {
    // ask the user to trim their database
  }
}
```

## Expiry is enforced on read, not by deletion

A transfer past its `expiresAt` is never handed over. The object itself, though, is still sitting
in your bucket: removing it is an S3 lifecycle rule's job, and s3nd does not create one for
you. [`s3nd doctor`](https://doc.s3nd.sh/docs/cli) checks whether you have one, because a bucket quietly filling
up with expired transfers is the most common way this goes wrong.
