API.

The same service the website is, driven from your own code: make a link, put files on it, hand the link to whoever needs the files, and delete it when you are done. On the Developer plan.

On this page
File bytes never touch this API

Creating a link hands you a signed URL per file, and you PUT the file body straight to storage. Our server sees filenames, sizes and content types — never the contents. That is why uploads are two calls and a PUT rather than one multipart POST, and it is not a detail we are willing to trade for a shorter example.

#Getting a key

Keys are made on your account page, and only on an account on the Developer plan. A key looks like fc_live_ and is shown once: only its SHA-256 is stored, so there is no way for anyone — us included — to show it to you again. Lose it and make another.

Send it as a bearer token. Whoever holds it is the account, so keep it out of client-side code, repositories and URLs. Revoking from the account page stops it immediately.

bash
curl https://filecamel.com/api/v1/shares \
  -H "Authorization: Bearer fc_live_your_key_here"

#Sending a file, end to end

Three steps. The middle one does not come here.

1 · make the link
curl -X POST https://filecamel.com/api/v1/shares \
  -H "Authorization: Bearer $FILECAMEL_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "files": [{ "name": "report.pdf", "size": 91234, "type": "application/pdf" }],
        "expiry": "7d"
      }'
response
{
  "slug": "k3f9mq2phx7vn",
  "url": "https://filecamel.com/k3f9mq2phx7vn",
  "manageCode": "CYMH-JDRK-QRH4-AVGV",
  "uploads": [{ "storageKey": "k3f9mq2phx7vn/9f2c….pdf", "url": "https://…" }]
}
2 · send the bytes
# Straight to storage, not to us. Content-Type must match what you
# declared, or the URL will not sign.
curl -X PUT "$SLOT_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @report.pdf
3 · tell us it arrived
# Sizes are read back from storage, not taken on trust.
curl -X POST https://filecamel.com/api/v1/shares/k3f9mq2phx7vn/complete \
  -H "Authorization: Bearer $FILECAMEL_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "uploads": [{ "storageKey": "k3f9mq2phx7vn/9f2c….pdf", "name": "report.pdf" }] }'

Until step 3 runs the link exists and is empty. That is deliberate: a half-finished upload should look like a link with nothing on it rather than a link with half a file on it. Step 3 is safe to retry — it records each object once, however many times it is called.

Files over 90 MB

Nothing above changes for them except one field. A file too large to send in a single request comes back with parts instead of url: PUT each slice to its own slot, keep the ETag each one answers with, and hand them back at step 3. Everything else — the slug, the manage code, the retry behaviour — is the same.

response · large file
{
  "storageKey": "k3f9mq2phx7vn/9f2c….zip",
  "size": 4294967296,
  "parts": {
    "uploadId": "AOTKUG6B0lbYUfMd…",
    "partSize": 10485760,
    "count": 410,
    "urls": ["https://…", "https://…"]
  }
}
3 · complete, with the parts
curl -X POST https://filecamel.com/api/v1/shares/k3f9mq2phx7vn/complete   -H "Authorization: Bearer $FILECAMEL_KEY"   -H "Content-Type: application/json"   -d '{ "uploads": [{
        "storageKey": "k3f9mq2phx7vn/9f2c….zip",
        "name": "archive.zip",
        "uploadId": "AOTKUG6B0lbYUfMd…",
        "parts": [{ "partNumber": 1, "etag": "\"a1b2…\"" }]
      }] }'

Slices are partSize bytes each, in order, the last one short. Send every part before completing — a missing one is refused rather than assembled into a file with a hole in it.

#Endpoints

POST/api/v1/sharesmake a link
FieldWhat it is
filesRequired. An array of { name, size, type }. Up to 50 of them, 10 GB each and 20 GB in one link. size is in bytes and is used to sign the slot; the real size is read back from storage at step 3.
expiry1d, 7d, 30d, or custom with minutes (10 to 525600). Defaults to 7d. When it passes, the files are deleted — not hidden, not archived.
passwordAt least 6 characters. The recipient types it before they see anything. Stored as a salted scrypt hash.
slugYour own code in the URL, 130 lowercase characters. A chosen code is a name, not a secret — words get guessed. Add a password if that matters. Returns 409 slug_taken if it is in use.
titleA label for your own list. Not shown to recipients.

Returns 201 with the slug, the public URL, one upload slot per file, and manageCode — which is sent exactly once and is the proof of ownership if you ever manage the link without the key.

POST/api/v1/shares/:slug/completeattach what arrived

Body: { "uploads": [{ "storageKey", "name", "contentType" }] }. Objects that are not in storage come back in missing by key, so you know which PUT to retry rather than which count to worry about. A storageKey outside this link's prefix is refused.

GET/api/v1/shares/:slugstatus

Files, sizes, download count, bytes served, expiry and abuse reports. Each file carries a state of downloadable, scanning or blocked, and a malware object. Read that one carefully: the check is a SHA-256 lookup against reports that already exist, so checked: false means nobody has ever analysed the file — not that it is clean. Rendering a green tick for it would be making a claim we are not making.

DELETE/api/v1/shares/:slugend it now

The objects go from storage immediately and the link starts saying it was deleted. Deleting something already deleted returns 200 with already: true — a retry after a timeout got the outcome it asked for, and should not read as a failure.

GET/api/v1/sharesyour links, newest first

?limit= up to 100, and ?before= the nextBefore from the previous page. Keyset paging, not an offset: the page does not shift under you while you are creating links. nextBefore is null on the last page.

#Rate limits

Per key, per hour — not per address, because a program on ten machines is one caller and a budget that resets per machine is not a budget. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After in seconds.

GroupPer hourCovers
create120POST /shares
write300complete, DELETE
read600GET /shares, GET /shares/:slug

Downloads are not on that list. A recipient fetches from storage directly, so downloading never touches this API — what governs it is the per-link transfer ceiling, the same one the website has.

#Webhooks

The interesting moments do not happen while you are asking about them. A recipient downloads at four in the morning, a link expires on its own schedule, a file fails its check an hour after it went up. Register an endpoint on your account page and we POST to it instead.

EventWhen
share.downloadedSomebody fetched a file, or the whole link as an archive. kind is file or archive.
share.expiredThe expiry passed and the files were deleted.
share.deletedYou ended the link early, from the API or the site.
file.blockedEnough engines flagged a file that the download is refused.
share.reportedA recipient reported the link. Carries the reason, never the reporter.

Only links made by your account produce events. Anything sent anonymously has nobody to notify.

What arrives

POST to your endpoint
POST https://your-server.example/filecamel
Content-Type: application/json
Filecamel-Event: share.downloaded
Filecamel-Delivery: 6c1f… (stable across retries)
Filecamel-Signature: t=1757000000,v1=9a2f…

{
  "event": "share.downloaded",
  "slug": "k3f9mq2phx7vn",
  "url": "https://filecamel.com/k3f9mq2phx7vn",
  "title": "Q3 report",
  "kind": "file",
  "file": { "id": "…", "name": "report.pdf", "size": 91234 },
  "sentAt": "2026-09-04T14:41:51.468Z"
}

Checking the signature

Not optional. An endpoint that skips this accepts anything anyone posts to it, and the URL is not a secret — it is in your config, your logs and ours.

HMAC-SHA256 over `${timestamp}.${rawBody}` with your signing secret. Sign the raw body, before any JSON parsing: re-serialising changes the bytes and the signature will not match. The timestamp is inside the signed string on purpose — signing the body alone gives a value that stays valid for ever, so a captured delivery could be replayed at you a year later.

node
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const t  = Number(/t=(\d+)/.exec(header)?.[1] ?? 0);
  const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1] ?? "";

  // Reject anything older than five minutes — this is what makes a
  // captured delivery worthless.
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const mine = crypto.createHmac("sha256", secret)
    .update(`${t}.${rawBody}`, "utf8").digest("hex");

  // Constant time. A fast reject leaks the signature one byte at a time.
  return v1.length === mine.length
    && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(mine));
}

Delivery, retries and being switched off

Any 2xx is success and the body is ignored — we never read it. Anything else, or a timeout past 10 seconds, is a failure and is retried: six attempts at 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours after the one before, then given up on. Redirects are never followed.

Delivery is at least once, not exactly once. A worker that dies after your server answered but before we record it will send again — so make your handler idempotent, and Filecamel-Delivery is the id to key that on. Answer quickly and do the work afterwards; ten seconds is the whole budget.

After 20 consecutive failures the endpoint is switched off and the account page says why. Fix it and switch it back on there; the queue does not keep hammering a URL nobody is coming back for.

The URL has to be https and must not be a private or link-local address — we are being asked to make requests from inside our network, and that is the shape of an SSRF.

#Errors

Every failure is { "error": { "code", "message" } }. Switch on code; message is written for a person reading a log and may be reworded.

StatuscodeMeaning
400bad_json, no_files, bad_expiry, bad_slug, bad_keyThe request. Fix and retry.
401no_key, bad_keyMissing, malformed or revoked key.
402plan_requiredThe key is real; the account is not on Developer. The key starts working the moment it is.
404not_foundNo such link on this account. Same answer either way, so a key cannot be used to find out which codes exist.
409slug_takenYour chosen code is in use.
410goneThe link expired or was deleted.
429rate_limitedOver budget. Retry-After says how long.
503storage_unavailableStorage did not answer. Nothing was created; retry is safe.

#Two things worth knowing

A link is a secret, not a permission. Anyone holding it can download until it expires. Sending the link is the same as sending the files — put a password on it if that is not what you want.

Deletion is real and immediate. There is no archive, no recycle bin, and no way for us to bring a file back — so never let a FileCamel link be the only copy of something you cannot lose.

#Something wrong?

Write to [email protected] with the request, what came back, and roughly when. Include the slug if there is one — never the key.