s3nd

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

app/api/transfers/[[...route]]/route.ts
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.

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.

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.

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." } }
codeHTTPWhat happened
INVALID_REQUEST400Malformed body, or a method the route does not serve
INVALID_SYNC_CODE400What was typed cannot be a code in this alphabet
UNAUTHORIZED401authorize refused
NOT_FOUND404No such code — or it expired
CODE_TAKEN409No free code found; vanishingly unlikely at 40 bits
SNAPSHOT_TOO_NEW409Written by a newer schema than this deployment reads
TOO_LARGE413Over the configured maxSize
INTERNAL500Anything 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.

On this page