Misi.Link

API documentation

Send files from your own tools (scripts, software, servers): create a transfer, upload the files in chunks, get the link.

Keys are available with the Pro and Business plans, in My account › API. Pricing

Authentication

Create a key in My account › API, then send it in the Authorization header of every request. Keep it secret: it gives access to your transfers. A deleted key stops working immediately.

Authorization: Bearer mlk_…

If your tool or hosting does not pass the Authorization header, send the same key in the X-API-Key header:

X-API-Key: mlk_…

120 requests per minute per key. Size, number of files and availability: those of your plan.

Send files in three steps

1. Create the transfer

Give the list of files (name and size in bytes). With “recipients”, each recipient gets an email; without recipients, you get a link to share. Optional: “message”, “password”, “expire_days”, “delete_after_download”.

curl -X POST https://misi.link/api/v1/transfers \
  -H "Authorization: Bearer $MISILINK_KEY" -H "Content-Type: application/json" \
  -d '{"files": [{"name": "film.mov", "size": 5368709120}],
       "recipients": ["client@example.com"], "message": "…", "expire_days": 14}'
{"ok": true, "id": "aB3dE5fG", "token": "…", "url": "https://misi.link/aB3dE5fG",
 "files": [{"id": 101, "name": "film.mov", "size": 5368709120}], "chunk_size": 8388608}

2. Upload the files

Send each file in chunks of at most “chunk_size” bytes, in order: “offset” is the number of bytes already received for that file. After an interruption, “status” tells you where to resume (409 response: resume at “received”). If your host or firewall filters URLs, send the position in the X-Upload-Offset header instead of the URL.

curl -X POST "https://misi.link/api/upload/chunk?id=aB3dE5fG&token=…&file=101&offset=0" \
  -H "Content-Type: application/octet-stream" --data-binary @morceau-1.bin
# {"ok": true, "received": 8388608, "done": false}

curl "https://misi.link/api/upload/status?id=aB3dE5fG&token=…"

3. Finish

When all files are received, finish: the transfer goes online and the emails are sent. The link is the one from the first response (“url”). This step is required. The server then stores the files: meanwhile, “status” is “finalizing”. A transfer received in full but never finished is finished automatically after 30 minutes without activity.

curl -X POST https://misi.link/api/upload/complete -H "Content-Type: application/json" \
  -d '{"id": "aB3dE5fG", "token": "…"}'

Direct upload to storage (recommended for large files)

With “direct”: true, your software sends the file straight to misi.link’s storage, without going through the server: maximum speed and online within seconds at the end. One file per transfer (several: put them in an archive). Without this option, or if the response contains “direct”: null, upload the chunks with “chunk” as above.

  1. Create the transfer with “direct”: true. The response gives “direct.part_size” (size of each part, the last one is shorter), “direct.parts” (number of parts) and “direct.sign”.
  2. Ask “sign” for the signed URLs (ID, token and part numbers, up to 100 per call; valid for 6 hours), then send each part with a plain PUT to its URL, without an authentication header. Parts can be sent in parallel and sent again after a failure.
  3. Finish with “complete”. If a part is missing, the response is “incomplete” with the “missing” list: send those parts again, then call “complete” again. End-to-end encryption: each part is a run of whole encrypted segments, in order (see the example).
curl -X POST https://misi.link/api/v1/transfers -H "Authorization: Bearer $MISILINK_KEY" -H "Content-Type: application/json" \
  -d '{"files": [{"name": "film.mov", "size": 5368709120}], "direct": true}'
# {…, "direct": {"part_size": 16777216, "parts": 320, "total": 5368709120, "sign": "https://misi.link/api/upload/sign"}}

curl -X POST https://misi.link/api/upload/sign -H "Content-Type: application/json" \
  -d '{"id": "aB3dE5fG", "token": "…", "parts": [1, 2, 3]}'
# {"ok": true, "urls": {"1": "https://…", "2": "https://…", "3": "https://…"}}

curl -X PUT -H "Content-Type: application/octet-stream" --data-binary @part-1.bin "https://…"

curl -X POST https://misi.link/api/upload/complete -H "Content-Type: application/json" -d '{"id": "aB3dE5fG", "token": "…"}'
# {"ok": false, "error": "incomplete", "missing": [2]}

End-to-end encryption (optional)

Your software encrypts the file before sending it: misi.link only receives and stores unreadable data. The key is never sent to the server: it sits in the link, after “#”. The recipient downloads and decrypts in their browser, on the usual page.

  1. Generate a random 16-byte key, then create the transfer with “e2e”: true and a single file (its plain size, in bytes). For several files, put them in an archive first.
  2. Encryption key: HKDF-SHA-256 of the key (salt “misilink-e2e-v1”, info = transfer ID, 16 bytes), for AES-GCM.
  3. Split the file into 4 MiB segments (4,194,304 bytes). Segment number i: nonce = 4 zero bytes then i on 8 bytes (big-endian); associated data = i on 8 bytes then 1 byte (1 for the last segment, otherwise 0). Each encrypted segment is 16 bytes longer: upload these bytes with “chunk”, in order (“e2e.encrypted_size” in total), then finish with “complete”.
  4. Recommended: “e2e_verify” = HKDF-SHA-256 of the key (same salt, info “misilink-verify”, 32 bytes) in base64url. misi.link then refuses a download with a wrong key, without ever knowing the right one.
  5. Link to share: “url” + “#” + the key in base64url (22 characters). With recipients (“recipients”), add “e2e_key” (the key): misi.link keeps it sealed to put it in the email links.
curl -X POST https://misi.link/api/v1/transfers \
  -H "Authorization: Bearer $MISILINK_KEY" -H "Content-Type: application/json" \
  -d '{"files": [{"name": "film.mov", "size": 5368709120}], "e2e": true, "e2e_verify": "…"}'
# {"ok": true, "id": "aB3dE5fG", …, "e2e": {"segment": 4194304, "tag": 16, "encrypted_size": 5368729600, "version": 1}}

Complete example in Python (cryptography library):

#!/usr/bin/env python3
# misi.link: end-to-end encrypted upload through the API (Python 3.8+, pip install cryptography)
# The file goes straight to the storage (signed URLs); if direct upload is unavailable, through "chunk".
# Usage: MISILINK_KEY=mlk_... python3 misilink_e2e.py file.mov [https://misi.link]
import base64, json, os, sys, urllib.request
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

SEG, SALT = 4 * 1024 * 1024, b'misilink-e2e-v1'
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode()
hkdf = lambda ikm, info, n: HKDF(SHA256(), n, SALT, info).derive(ikm)


def call(url, body=None, raw=None, headers=None, method=None, auth=True):
    h = dict(headers or {})
    if auth:
        h['Authorization'] = 'Bearer ' + os.environ['MISILINK_KEY']
    data = raw if raw is not None else (json.dumps(body).encode() if body is not None else None)
    if data is not None:
        h.setdefault('Content-Type', 'application/octet-stream' if raw is not None else 'application/json')
    req = urllib.request.Request(url, data=data, headers=h, method=method or ('POST' if data is not None else 'GET'))
    with urllib.request.urlopen(req, timeout=600) as r:
        body = r.read()
        return json.loads(body) if body[:1] == b'{' else {}


def segments(path, key, size):
    n = max(1, -(-size // SEG))
    with open(path, 'rb') as f:
        for i in range(n):
            last = i == n - 1
            nonce = bytes(4) + i.to_bytes(8, 'big')
            aad = i.to_bytes(8, 'big') + (b'\x01' if last else b'\x00')
            yield key.encrypt(nonce, f.read(SEG), aad)        # SEG bytes in, SEG + 16 bytes out


def send(path, base='https://misi.link'):
    size = os.path.getsize(path)
    secret = os.urandom(16)                                   # the key: never sent to the server
    verify = b64(hkdf(secret, b'misilink-verify', 32))       # lets the server recognise the right key
    t = call(base + '/api/v1/transfers', {'files': [{'name': os.path.basename(path), 'size': size}],
                                         'e2e': True, 'e2e_verify': verify, 'direct': True})
    key = AESGCM(hkdf(secret, t['id'].encode(), 16))
    auth = {'id': t['id'], 'token': t['token']}
    d = t.get('direct')
    if d:                                                     # straight to the storage: parts of d['part_size'] bytes
        part, n, urls = b'', 0, {}
        def put(n, data):
            if str(n) not in urls:
                urls.update(call(d['sign'], {**auth, 'parts': list(range(n, min(n + 100, d['parts'] + 1)))})['urls'])
            call(urls[str(n)], raw=data, method='PUT', auth=False)
        for seg in segments(path, key, size):
            part += seg
            if len(part) == d['part_size']:
                n += 1; put(n, part); part = b''
        if part:
            n += 1; put(n, part)
    else:                                                     # through the server
        sent = 0
        for seg in segments(path, key, size):
            r = call(t['upload']['chunk'] + '?id=' + t['id'] + '&token=' + t['token'] + '&file=' + str(t['files'][0]['id']),
                     raw=seg, headers={'X-Upload-Offset': str(sent)})
            sent = r['received']
    call(t['upload']['complete'], auth)
    return t['url'] + '#' + b64(secret)                      # the key travels after "#", only in the link


if __name__ == '__main__':
    print(send(sys.argv[1], *(sys.argv[2:3] or [])))

Other requests

GET /api/v1/transfers
Your transfers, 50 per page (?page=2…).
GET /api/v1/transfers/{id}
One transfer: status, link, files, number of downloads, expiry date.
DELETE /api/v1/transfers/{id}
Delete a transfer (the link stops working).
GET /api/v1/me
Your account, your plan and its limits, chunk size.

Dates in ISO 8601 format (UTC), sizes in bytes.

Responses and errors

All responses are JSON, with “ok”. On error: “error” (short code) and “message”. HTTP codes: 401 missing or invalid key, 403 plan without API, 404 not found, 409 chunk in the wrong place, 422 request refused, 429 too many requests (wait a minute).