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.
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.
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"
}'
{
"slug": "k3f9mq2phx7vn",
"url": "https://filecamel.com/k3f9mq2phx7vn",
"manageCode": "CYMH-JDRK-QRH4-AVGV",
"uploads": [{ "storageKey": "k3f9mq2phx7vn/9f2c….pdf", "url": "https://…" }]
}
# 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
# 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.
{
"storageKey": "k3f9mq2phx7vn/9f2c….zip",
"size": 4294967296,
"parts": {
"uploadId": "AOTKUG6B0lbYUfMd…",
"partSize": 10485760,
"count": 410,
"urls": ["https://…", "https://…"]
}
}
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
| Field | What it is |
|---|---|
| files | Required. 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. |
| expiry | 1d, 7d, 30d, or custom with minutes (10 to 525600). Defaults to 7d. When it passes, the files are deleted — not hidden, not archived. |
| password | At least 6 characters. The recipient types it before they see anything. Stored as a salted scrypt hash. |
| slug | Your own code in the URL, 1–30 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. |
| title | A 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.
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.
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.
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.
?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.
| Group | Per hour | Covers |
|---|---|---|
| create | 120 | POST /shares |
| write | 300 | complete, DELETE |
| read | 600 | GET /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.
| Event | When |
|---|---|
| share.downloaded | Somebody fetched a file, or the whole link as an archive. kind is file or archive. |
| share.expired | The expiry passed and the files were deleted. |
| share.deleted | You ended the link early, from the API or the site. |
| file.blocked | Enough engines flagged a file that the download is refused. |
| share.reported | A 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 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.
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.
| Status | code | Meaning |
|---|---|---|
| 400 | bad_json, no_files, bad_expiry, bad_slug, bad_key | The request. Fix and retry. |
| 401 | no_key, bad_key | Missing, malformed or revoked key. |
| 402 | plan_required | The key is real; the account is not on Developer. The key starts working the moment it is. |
| 404 | not_found | No such link on this account. Same answer either way, so a key cannot be used to find out which codes exist. |
| 409 | slug_taken | Your chosen code is in use. |
| 410 | gone | The link expired or was deleted. |
| 429 | rate_limited | Over budget. Retry-After says how long. |
| 503 | storage_unavailable | Storage 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.