Skip to content
s3nd
Menu

Your bucket · A code · No account

Send anything with a code.

s3nd drops a file into an S3 bucket you own and hands you eight characters. Whoever has the code picks it up, from any machine, until it expires. From a terminal, from your own app, or from curl.

npm i -g @s3nd/clis3nd put ./anything.zip
transfer · drop/K7QP2M4Xyour bucketexpires 1h
$ s3nd put ./report.pdf
report.pdf · 284 kB · expires in 1 hour
$ s3nd get k7qp-2m4xany other machine
Wrote ./report.pdf · 284 kB
$ s3nd rm k7qp-2m4x
Burned K7QP2M4X gone
  • No account
  • No relay
  • Nothing to deploy
  • Your bucket
  • AWS S3
  • Cloudflare R2
  • MinIO
  • Scaleway
  • Wasabi
  • Expires on its own
  • 40-bit codes
  • CLI · Library · React
  • MIT

01Put. Code. Get.

The whole product is three commands.

The bytes go from one machine to your bucket and from your bucket to the other. Nothing streams through anyone else's server, and nobody signs up for anything.

  1. put

    Drop it in your bucket.

    A file, a folder as an archive, whatever comes down stdin. One PutObject into a bucket you own, under a fresh code, with an expiry stamped on the object. The code is printed and nothing else, so it composes.

  2. code

    Read it over the phone.

    Eight characters, forty bits, no I, L, O or U. Write it on a sticky note, type it in the wrong case with a dash in the middle: it still resolves. The server picks it and claims it with a conditional write.

  3. get

    Pick it up anywhere.

    Any machine with the code and access to the bucket, or a token for your server, gets the file back. Until the transfer expires, or you burn it. The other machine did not have to be on when you sent.

02Three ways to send

A terminal, your own app, or anything that speaks HTTP.

One primitive under all three. The server-side package holds the keys; everything that runs in a browser depends on fetch and nothing else.

From a terminal

@s3nd/cli

Straight to the bucket with the credentials on that machine, nothing deployed. Or through your server with a token and --remote. A doctor command proves the setup works.

$ s3nd put ./build.tar.gz
build.tar.gz · 41 MB · expires in 1 day
K7QP2M4X

$ tar cz ./photos | s3nd put - --name photos.tar.gz
$ CODE=$(s3nd put ./report.pdf)     # code on stdout, the rest on stderr

# any other machine
$ s3nd get k7qp-2m4x
Wrote ./build.tar.gz · 41 MB
Every command

Inside your app

s3nd@s3nd/react

One route file serves the four-route protocol on your domain, with your auth. React hooks send a file, read a code back, and repair what the user typed.

// app/api/transfers/[[...route]]/route.ts
import { createBucket, createTransferHandler } from 's3nd'

export const { GET, POST, DELETE } = createTransferHandler({
  bucket: createBucket({ bucket: 'drop' }),
  expiresIn: 24 * 3600,
  authorize: (request) => request.headers.get('authorization') === `Bearer ${process.env.TOKEN}`,
})

// In the browser, with @s3nd/react:
const { sendFile, transfer } = useSendTransfer()
await sendFile(file) // → transfer.code

Anything with HTTP

@s3nd/protocol

The protocol is four routes and one error format, written down. curl works, a Go client works, and a server in Rails works with every s3nd client.

$ curl -X POST https://drop.example.com/api/transfers \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/pdf" \
    -H "X-S3nd-Filename: report.pdf" \
    --data-binary @report.pdf
{ "code": "K7QP2M4X", "kind": "file", "size": 290816, "expiresAt": "…" }

$ curl -LOJ -H "Authorization: Bearer $TOKEN" \
    https://drop.example.com/api/transfers/K7QP2M4X/raw
The protocol spec

03Your bucket

Nobody in the middle.

Every other tool in this space either runs a relay, hosts your files, or asks for an account. s3nd is a thin layer over object storage you already pay for.

No relay
Machine to bucket, bucket to machine. Your provider’s durability, your provider’s bill, and on R2 no egress fee at all.
No account
A code is the whole handshake. On your own server, a bearer token per person is the most identity s3nd ever asks for.
Nothing to deploy
The CLI talks to the bucket directly. A server enters the picture only when a browser has to, and it is one route file.
Expires on its own
Every transfer carries an expiry, checked on every read. A lifecycle rule deletes the object, and s3nd doctor checks you have one.

04Sync codes

A code you can read over the phone.

The whole experience of a transfer is someone reading a code off one screen and typing it into another. Everything about the code is shaped by that.

What codes.normalize() looks up

Complete. Separators dropped, case folded, and O, I and L read as 0, 1 and 1, because Crockford base32 has no O, I or L to confuse them with.

Alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ · 8 chars · 40 bits

The default is eight characters of Crockford base32: no I, L, O or U, so a code survives paper, a phone keyboard and a phone call. Generation and normalization live on the same object, so the two sides can never disagree about the alphabet.

Four digits for a phone-first app, twelve alphanumerics for a long-lived drop: both halves are configurable, and entropyBits tells you what the code is worth guessing against so the rate limit can do the rest.

A code is a bearer token. Give it a short expiry, rate-limit the lookup route, and for sensitive payloads encrypt before anything reaches the bucket.

05Also

Not only files. An app's whole state.

A transfer can be structured data as well as bytes. That is how a local-first app with no accounts carries its database to the user's new phone: the browser exports IndexedDB, the server snapshots it, the other phone types the code.

import { createBucket } from 's3nd'

const store = createBucket({ bucket: 'my-bucket', prefix: 'snapshots' })

// On the old phone: hand the user a code.
const code = store.codes.create() // "K7QP2M4X"
await store.putSnapshot(code, state, { app: 'notes', version: 3, expiresIn: 3600 })

// On the new phone: they type it in.
const snapshot = await store.getSnapshot(store.codes.normalize(typed), { maxVersion: 3 })
snapshot?.data // → ready to write back into IndexedDB

A self-describing envelope

Your app name, your schema version, the device, an expiry, then the data, gzipped. A restore refuses a snapshot from a newer build instead of misreading it, and shows when and where it was made before replacing anything.

Two devices, one backup, no silent loss

Pass the ETag you last read as ifMatch and a device that writes after someone else did gets an error instead of overwriting their work.

07Compared

Why not croc, WeTransfer or rclone?

Sometimes they are the right answer. The comparison pages say when, tool by tool, in the other tool's own terms.

08Questions

The ones that come up.

01What is s3nd?

A way to send anything with a code. s3nd put drops a file into an S3 bucket you own and prints eight characters; s3nd get on any other machine, with the code, downloads it. The same primitive runs inside your own app as a library and React hooks, and it carries structured data as well as files. How it works

02How is it different from WeTransfer, croc or Magic Wormhole?

The bytes go into your bucket and nowhere else: no hosted service, no relay, no account on either end. And unlike a live peer-to-peer transfer, the sender leaves and the code is redeemed later, until it expires. The comparison pages go tool by tool and say when the other one is the better choice. Compare

03Do I need a server?

Not for machines you control: the CLI talks to the bucket directly with the credentials on that machine, and there is nothing to deploy. You need one as soon as a browser takes part, because a browser cannot hold S3 credentials. s3nd ships that server as one route file, and the CLI talks to it with a token instead of keys. Without a server

04Which storage providers work?

AWS S3 and anything that speaks the S3 API: Cloudflare R2, MinIO, Scaleway Object Storage, Wasabi, Ceph, Garage and the rest. Set an endpoint and s3nd switches the two defaults those providers expect. s3nd init writes a starter configuration per provider, and s3nd doctor proves it works before you rely on it. Storage providers

05Is a code secure?

A code is a bearer token: whoever has it can read that one transfer while it lives. The default is eight Crockford base32 characters, forty bits, which is sound when a transfer expires within a day and the lookup route is rate-limited. For sensitive payloads, encrypt before sending; s3nd stores whatever bytes you hand it. Sync codes

06How big can a file be?

From the CLI straight to the bucket, a file goes up in one PutObject, so it is bound by the memory of the sending machine rather than by a request limit: hundreds of megabytes are fine, multi-gigabyte archives wait for multipart, which is on the roadmap. Through your server, the runtime's request limit applies, 4.5 MB on Vercel functions and 6 MB on Lambda, unless you presign. Limits

07What happens when a transfer expires?

It is never handed over again: the expiry is checked on every read, and an expired code answers the same NOT_FOUND as one that never existed. The object itself is deleted by a lifecycle rule on your bucket, which s3nd doctor checks you have, because a bucket quietly filling up with expired transfers is the most common way this goes wrong.

08Can it move an app's data, not only files?

Yes. A snapshot is structured state wrapped in an envelope with your app name, a schema version and an expiry, gzipped, and stored under a code. A local-first app exports its IndexedDB, posts it, and the user types the code on their other phone: no account, and the receiving build refuses a snapshot from a newer schema. That is where s3nd started. Move an app to a new device

09What does it cost?

Nothing. Every package is MIT-licensed and there is no hosted service in the middle. The only bill is what your storage provider charges for a few objects that expire, and on Cloudflare R2 egress is free.

10Which runtimes does it run on?

The library and the CLI need Node 20 or later, because the AWS SDK does. The transfer handler takes a Request and returns a Response, so it drops into Next.js, Hono, Bun.serve, Deno or a worker without an adapter. The browser packages depend on fetch and nothing else, so they run in a browser, a worker or React Native.

Put a file. Hand over the code.

Point it at the bucket you already pay for. Nothing to deploy, nothing to sign up for, nothing in the middle.

npm install -g @s3nd/clis3nd init --provider r2 --bucket drops3nd put ./anything.zip