# How it works

> A transfer is an object in your bucket under an eight-character code, with an expiry checked on every read. Files, snapshots, the four-route protocol, conditional writes, and why the packages are split the way they are.

Canonical: https://www.s3nd.sh/how-it-works · Markdown: https://www.s3nd.sh/how-it-works.md · Français: https://www.s3nd.sh/fr/how-it-works

s3nd is deliberately small. This page is the whole of it: what a transfer is, what a code is, the protocol between a server and its clients, and why the packages are split the way they are.

**Two machines. Your bucket between them. Nothing else.**

- **One machine puts** — A file, or an app’s data, lands in your bucket as one object under a fresh eight-character code, with the expiry stamped on the object.
- **Eight characters change hands** — Read out loud, pasted in a chat, scanned off a screen. The code is the whole handshake: no account on either side, nothing between the two machines but your bucket.
- **Another machine gets** — The code is repaired as typed and the expiry checked on read, then the object is handed over. Burn it when you are done, or let the expiry do it.

## One object, one code, one expiry.
A file is stored as the bytes you gave, with the filename, the content type and the expiry in the object’s metadata. Structured data is stored as a snapshot: a self-describing envelope, gzipped. Both sit under the code.

**a file, as it lands in the bucket**
```text
drop/K7QP2M4X
  Content-Type:         application/pdf
  Content-Disposition:  attachment; filename="report.pdf"
  x-amz-meta-s3nd-kind:        file
  x-amz-meta-s3nd-expires-at:  2026-08-27T13:00:00.000Z
  Body:                 the bytes, untouched
```
**a snapshot, as it lands in the bucket**
```jsonc
{
  "s3nd": 1,              // envelope format, not your data's
  "app": "notes",
  "version": 3,           // your schema version
  "device": "Pixel 8",
  "createdAt": "2026-08-27T12:00:00.000Z",
  "expiresAt": "2026-08-27T13:00:00.000Z",
  "data": { /* whatever you passed */ }
}                          // gzipped, typically 5–10× smaller
```

- **expiry on read** — A transfer past its expiry is never handed over, even if the object is still sitting in the bucket. An expired code answers the same `NOT_FOUND` as one that never existed, so nobody can probe which codes were used.
- **lifecycle rule** — Deleting the object is your bucket’s job, through a lifecycle rule on the prefix. `s3nd doctor` checks you have one, because a bucket quietly filling up with expired transfers is the most common way this goes wrong.
- **schema versions** — A snapshot carries your schema version. Pass `maxVersion` on read and a snapshot from a newer build throws `SNAPSHOT_TOO_NEW` instead of landing in an app that will misread it.
- [Snapshots](https://doc.s3nd.sh/docs/snapshots)
- [How big a transfer can be](https://doc.s3nd.sh/docs/limits)

## Forty bits that survive a phone call.
A code is the whole user experience of a transfer. It appears on one screen and someone types it into another, and everything about it is shaped by that.

- **Crockford base32** — No `I`, `L`, `O` or `U`. The first three are what people misread; dropping the fourth keeps a random code from spelling something unfortunate.
- **Normalized on the way back** — Separators dropped, case folded, and `O` read as zero only when there is no letter O to confuse it with. The repair happens in the browser, before any request.
- **Claimed with a conditional write** — The server picks the code and writes with `ifAbsent`, so a collision fails loudly and retries with a fresh code instead of overwriting a stranger’s transfer.
- [Sync codes](https://doc.s3nd.sh/docs/sync-codes)
- [Length, alphabet, and what each costs](https://doc.s3nd.sh/docs/code-configuration)

## Four routes, one error format. Written down.
A browser cannot hold your S3 credentials, so when one takes part a server sits in the middle. The shape of that middle is a protocol, not whatever the handler happens to do.

**relative to wherever you mounted it**
```text
POST   /                # create a transfer, get the code back
GET    /:code           # metadata; the state inline for a snapshot
GET    /:code/raw       # the bytes, or a 302 to a presigned URL
DELETE /:code           # burn it

# every error, same shape
{ "error": { "code": "NOT_FOUND", "message": "Unknown or expired code." } }
```
**the client, in a browser**
```ts
import { createTransferClient } from '@s3nd/protocol'

const transfers = createTransferClient({ baseUrl: '/api/transfers' })

const { code } = await transfers.createFile({ body: file, filename: file.name })
const meta = await transfers.read(typed)        // null when unknown or expired
const bytes = await transfers.readBytes(code)   // the file back
await transfers.remove(code)
```

A client works against any server that answers these routes, not only against `createTransferHandler()`. A server in Go or Rails works with every s3nd client. And the CLI pointed at `--remote` cannot tell which it is talking to, which is exactly why `s3nd put` works against your own deployment.

What you send decides what a transfer holds: a JSON body is a snapshot, any other content type is a file with its name in `X-S3nd-Filename`. Clients throw a `TransferError` carrying the error code; branch on the code, never on the message.

[The transfer protocol, route by route](https://doc.s3nd.sh/docs/protocol)

## When two machines write, last-write-wins is data loss.
A one-shot transfer has a single writer. A per-user backup has two, and S3’s default silently keeps whichever arrived last.

```ts
// Claim a fresh code: write only if nothing sits under it.
await store.putSnapshot(code, state, { ifAbsent: true })

// Rewrite a shared backup: fail if someone wrote since you read.
const current = await store.getSnapshot(`user-${userId}`)
await store.putSnapshot(`user-${userId}`, merged, { ifMatch: current?.etag })
// → PRECONDITION_FAILED when another device won. Read again, merge again.
```

Both options are plain S3 conditional headers. `ifAbsent` is how a fresh code is claimed; `ifMatch` is how a second device finds out it lost the race. They work on every provider that implements them, and the node example is where you find out whether yours does.

[Two devices, one snapshot](https://doc.s3nd.sh/docs/two-devices)

## One constraint decides the split.
A browser must never end up with a storage client in its dependency tree. The protocol package is what both halves share, which is why it exists at all.

- **@s3nd/cli** (The binary; s3nd) — put, get, rm, doctor, init and config. One implementation, the protocol client, wired either to fetch or straight into the handler in-process.
- **@s3nd/core** (The primitive; aws-sdk, protocol) — Files, snapshots, conditional writes and the transfer handler. The only package that holds credentials, so the only one that runs on a server.
- **@s3nd/protocol** (The contract; nanoid) — The wire format, a fetch-based client, and the sync codes. Nothing here imports a storage client, which is what lets a browser share it.
- **@s3nd/react** (The hooks; protocol, react (peer)) — Send, receive, and a code input. Depends on the protocol and never on S3, so no path from your bundle reaches the AWS SDK.

- **Server runtime**: Node 20 or later, what the AWS SDK v3 requires
- **Handler**: Request in, Response out: Next.js, Hono, Bun.serve, Deno, workers
- **Browser packages**: fetch and nothing else: browser, worker, React Native, Deno
- **Tests**: Offline, against an in-memory S3 that honours conditional headers
