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.
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
import { createBucket, createTransferHandler } from 's3nd'
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.
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.
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
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 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.
{ "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.
{
"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.
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:
{ "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:
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 checks whether you have one, because a bucket quietly filling
up with expired transfers is the most common way this goes wrong.