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, on any account — the first one comes with 100 free credits. 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 — and links made with a key, being on an account, have no transfer ceiling.
#Credits
Creating a link spends credits: 1 for the link, plus 1 for every started gigabyte for every started week it is kept. Everything else — reading, listing, deleting, webhooks, downloads — is free. Credits are bought on the pricing page, once, and do not expire.
The charge is made when the link is created, from the sizes you declare, and the response says what it cost and what is left: "credits": { "charged": 2, "balance": 98 }, and the same balance in an x-credits-remaining header. If storage then holds more than you declared, complete charges the difference before attaching anything. If the account cannot pay, the call is refused with 402 insufficient_credits and nothing is created.
{
"balance": 98,
"pricing": { "perLink": 1, "perGbWeek": 1, "creditUsd": 0.015 }
}
#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 | insufficient_credits | Not enough credits for this link. Nothing was created; cost and balance say by how much. Buy more and retry. |
| 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 contact@filecamel.com with the request, what came back, and roughly when. Include the slug if there is one — never the key.