Skip to content
colis

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

  1. 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.

  2. 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.

  3. 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.

You send a file to your bucket and get a code; the code crosses to your client; your client opens it, previews it and approves it, and a webhook is sent. Replays every fourteen seconds; under reduced motion, the finished delivery is shown instead.

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.

  1. 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.

  2. ouvert · opened

    The first time someone other than you opens the pickup page, downloads the file or answers. Only the first time.

  3. validé · approved

    The client pressed approve. Final: a second answer is refused.

  4. à 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.

the receipt, from a terminal
$ 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 script

The 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.

Webhooks and n8n

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.

a parcel, as it lands in the bucket
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 comment
expiry on read

A 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.

lifecycle rule

Deleting the object is your bucket’s job, through a lifecycle rule on the prefix. colis verifier checks you have one.

password

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           # abandoned

Parts 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.

Deliver heavy files

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.

relative to /api/transfers
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." } }
the client, in a browser
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 route

06The 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, protocol
The binary

envoyer, 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, protocol
The primitive

Files, records, conditional writes and the transfer handler. The only package that holds credentials.

@colis/protocol

nanoid
The contract

The wire format, a fetch-based client, the codes, and the webhook signature: signWebhook and verifyWebhook.

@colis/react

protocol, react (peer)
The hooks

Send, receive, and a code input. No path from your bundle reaches the AWS SDK.

n8n-nodes-colis

protocol, n8n-workflow (peer)
The n8n node

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.