Under the label
A file in your bucket, an answer from your client.
colis is deliberately small. This page is the whole of it: what a parcel is, what the receipt records, how large files travel, and the protocol the page, the CLI and the automations share.
You. Your bucket. Your client. Nothing else.
● a whole delivery · 14 s · on a loop
- 01 · send
You send
The file lands in your bucket as one object under a fresh eight-character code, with its expiry stamped on the object and a receipt beside it.
- 02 · the code
The code changes hands
A link in an email, eight characters read out on a call, a QR code on a screen. No account on either side.
- 03 · sign-off
Your client answers
They type the code, typos repaired, preview the file and approve it. The receipt records it, and the webhook goes out.
01The receipt
Sent, opened, approved or changes.
Every parcel gets a receipt, kept beside it in your bucket: four states, each with its time, and one answer that never changes.
- envoyé · sent
Written when the parcel is created, with a hash of your sender token. That token is how your own visits are told apart from your client’s.
- ouvert · opened
The first time someone other than you opens the pickup page, downloads the file or answers. Only the first time.
- validé · approved
The client pressed approve. Final: a second answer is refused.
- à corriger · changes
The client asked for changes and said which, in up to 1,000 characters. Final too: the next version is a new parcel.
$ colis statut K7QP2M4X
à corriger
envoyé 2026-09-22 10:00
ouvert 2026-09-22 11:30
à corriger 2026-09-22 12:45
comment Le logo en SVG, et le fond plus clair.
$ colis statut K7QP2M4X --json # the whole receipt, for a scriptThe receipt outlives the file: it still answers after a one-time download burned the code, for as long as the deployment’s longest lifetime. Your sending page follows it on its own, colis statut prints it, and each change is a webhook.
02A parcel
One object, one code, one lifetime.
The file is stored as the bytes you gave, with its name, its type and its expiry in the object’s metadata. Everything else the page knows sits in small records beside it, under `meta/`, out of reach of the transfer routes.
drop/K7QP2M4X # the file
Content-Type: application/pdf
Content-Disposition: attachment; filename="maquette-v2.pdf"
x-amz-meta-colis-kind: file
x-amz-meta-colis-expires-at: 2026-09-23T10:00:00.000Z
Body: the bytes, untouched
drop/meta/K7QP2M4X # note, device, one-time flag, password hash
drop/meta/K7QP2M4X.delivery # sent, and the sender's token hash
drop/meta/K7QP2M4X.opened # the first time the client opened it
drop/meta/K7QP2M4X.verdict # approved, or changes and the commentA parcel past its expiry is never handed over, even if the object is still in the bucket. An expired code answers the same NOT_FOUND as one that never existed.
Deleting the object is your bucket’s job, through a lifecycle rule on the prefix. colis verifier checks you have one.
A parcel’s password is hashed with scrypt under its own salt. Only the hash is stored, so nobody can read it back or reset it.
03Large files
Straight from the browser to your bucket.
Above `DROP_MAX_SIZE_MB`, nothing goes through the function. The server signs URLs and assembles; the browser sends the parts itself.
POST /api/transfers/uploads # a fresh code, a multipart upload
→ { code, uploadId, partSize, partCount } # parts of 8 MiB
POST /api/transfers/uploads/:code/parts # presigned PUT URLs, 50 at most
PUT <the bucket> # the browser, 4 parts at a time
POST /api/transfers/uploads/:code/complete # assembled, checked, then 201
DELETE /api/transfers/uploads/:code # abandonedParts are 8 MiB, larger when a file would need more than 10,000 of them. The browser sends four at a time and retries a failed one three times.
It also remembers the upload. Drop the same file again after a lost connection or a closed tab, and it asks the bucket which parts are already there and sends the rest.
The bucket needs a CORS rule that allows PUT from your page and exposes ETag. Without it, small files keep working and a large one fails with a message that says why.
04The code
Forty bits that survive a phone call.
The code is what your client handles. It appears on your screen and they type it into theirs, 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.
Repaired on the way back
Separators dropped, case folded, and O read as zero. The repair happens in the browser, before any request, and what the client typed stays as typed.
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 someone else’s parcel.
05The protocol
Four routes, one error format. Written down.
A browser cannot hold your S3 credentials, so a server sits in the middle. The delivery page is that server, and its shape is a protocol, not whatever the handler happens to do.
POST / # create a transfer, get the code back
GET /:code # metadata
GET /:code/raw # the bytes, or a 302 to a presigned URL
DELETE /:code # burn it
# what the delivery page adds
GET /:code/status # sent, opened, approved or changes
POST /:code/opened # the client opened it
POST /:code/verdict # { "decision": "approved" }, or "changes" and a comment
# every error, same shape
{ "error": { "code": "NOT_FOUND", "message": "Unknown or expired code." } }import { createTransferClient } from '@colis/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)The CLI pointed at --remote speaks the same four routes, which is why colis envoyer works against your delivery page with a token instead of S3 keys.
The delivery page adds three routes of its own for the receipt. A client that knows none of them still works: what it creates is a parcel like any other, with the deployment’s defaults.
The transfer protocol, route by route06The packages
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.
@mdemb/colis
core, protocolenvoyer, recevoir, statut, annuler, verifier, init and config. One implementation, the protocol client, wired to fetch or straight into the handler in-process.
@colis/core
aws-sdk, protocolFiles, records, conditional writes and the transfer handler. The only package that holds credentials.
@colis/protocol
nanoidThe wire format, a fetch-based client, the codes, and the webhook signature: signWebhook and verifyWebhook.
@colis/react
protocol, react (peer)Send, receive, and a code input. No path from your bundle reaches the AWS SDK.
n8n-nodes-colis
protocol, n8n-workflow (peer)A trigger that checks the signature, and an action node that sends, reads and burns parcels.
- Server runtime
- Node 20 or later, what the AWS SDK v3 requires
- Delivery page
- Next.js, deployed on Vercel from the monorepo
- Browser packages
- fetch and nothing else
- On npm
- Not yet: built from the repository
Send it. They sign off. You know.
Your storage, a delivery page under your name, a webhook at every step. Nothing hosted by someone else.