Storage
S3-compatible object storage with presigned uploads and CDN delivery, so user files never touch your own server.
What it is
S3-compatible object storage for your app: upload a file through us or straight to S3 with a presigned URL, and get back a stable public download URL you can put in an <img> tag. Every object is namespaced to your app, so one app can never read or delete another's files.
Before you start
You need your app API key. It is shown once, when the app is created. If you no longer have it, open the Smart Services console → your app → Settings and use Rotate Key — the old key stops working immediately.
Send it on every request, in either header:
Authorization: Bearer <key>
x-api-key: <key>
Both are accepted. Authorization: Bearer wins if you send both.
Base URL: https://smart-services.io
The storage service must be enabled on your app, or every endpoint here returns 403.
The key is server-side only. It never belongs in browser code — which is exactly what the presign flow below exists for.
Your first call
Upload a file. This endpoint takes multipart form-data, not a raw body — file is the only required field.
curl -X PUT https://smart-services.io/api/v1/storage \
-H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
-F "file=@invoice.pdf" \
-F "key=invoices/2026-09/invoice.pdf" \
-F "catalog=acme-ltd"
The same call with fetch, from your server:
const form = new FormData();
form.append("file", file); // a File or Blob
form.append("key", "invoices/2026-09/invoice.pdf");
form.append("catalog", "acme-ltd");
const res = await fetch("https://smart-services.io/api/v1/storage", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
// Do NOT set Content-Type — fetch writes the multipart boundary for you.
},
body: form,
});
const data = await res.json();
console.log(data.publicUrl);
The response:
{
"success": true,
"uploadId": "cmu1b7k4x0031229rq8w2m5td",
"key": "invoices/2026-09/invoice.pdf",
"etag": "\"9f2b1c7e4a1d55c0a3e88b21f0c4d7aa\"",
"size": 184320,
"publicUrl": "https://smart-services.io/api/v1/storage/download/cmu1b7k4x0031229rq8w2m5td",
"optimized": false
}
uploadId is how you refer to the file afterwards, for everything. publicUrl needs no API key and can go straight into an <img> or <a>.
The rest of the endpoints
| Method | Path | What it does |
|---|---|---|
| PUT | /api/v1/storage | Upload via multipart form-data. Fields: file (required), key, contentType, catalog, optimisations. |
| GET | /api/v1/storage?uploadId=<id> | Download the bytes, authenticated with your app key. |
| POST | /api/v1/storage | Describe one object. Body { "uploadId": "..." }. Returns key, size, lastModified, contentType, etag. |
| DELETE | /api/v1/storage?uploadId=<id> | Delete the object and free the quota it held. |
| GET | /api/v1/storage/list | List your uploads. Query: limit (max 100), skip, catalog, contentType, search. |
| POST | /api/v1/storage/presign | Get a presigned S3 URL for a direct browser upload. |
| POST | /api/v1/storage/presign/confirm | Tell us the direct upload finished, so the record and quota are updated. |
| GET | /api/v1/storage/download/<uploadId> | Public. No auth. Serves the file and records the download. |
The presign flow
Use this for anything large, or for browser uploads where you do not want the bytes passing through us. It is three steps, and all three are required — this is the part people get wrong.
1. Ask for a URL. size is mandatory: it is what the quota check runs against before we hand out a signature.
curl -X POST https://smart-services.io/api/v1/storage/presign \
-H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"key": "video/keynote.mp4",
"contentType": "video/mp4",
"size": 734003200,
"catalog": "events-2026",
"expiresIn": 3600
}'
{
"success": true,
"uploadUrl": "https://s3.example.com/bucket/apps/cmt.../video/keynote.mp4?X-Amz-Signature=...",
"uploadId": "cmu1c02m70045229rz6p3n8qe",
"publicUrl": "https://smart-services.io/api/v1/storage/download/cmu1c02m70045229rz6p3n8qe",
"expiresIn": 3600,
"expiresAt": "2026-09-18T15:12:44.000Z"
}
This writes an upload record with status pending. Nothing is stored yet and no quota is consumed yet.
2. PUT the bytes to uploadUrl yourself. Straight to S3, not to us. Send the same Content-Type you passed to /presign — it is part of what was signed, and a different one fails the signature check.
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "video/mp4" },
body: file,
});
3. Confirm. This is not bookkeeping you can skip.
curl -X POST https://smart-services.io/api/v1/storage/presign/confirm \
-H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "uploadId": "cmu1c02m70045229rz6p3n8qe" }'
Confirm does a HEAD against S3 to prove the object is really there, then flips the record to uploaded, replaces your declared size with the actual size, and records the real contentType and etag. Until it runs, the file is invisible to list, will not download, and does not count against your quota.
Limits and errors
Errors are JSON with an error string, sometimes with a hint telling you what to do next:
{
"error": "Upload not found or already confirmed",
"hint": "Make sure the uploadId is correct and the upload has not been confirmed yet"
}
| Status | Means |
|---|---|
400 | Missing file, missing key or size on presign, unparseable multipart, bad JSON. |
401 | Missing or invalid API key. |
402 | Quota exceeded. The body carries quota, used, requested and available. |
403 | Storage is not enabled on your app. |
404 | No such uploadId for your app, or the object is missing from S3. |
500 | Storage is misconfigured, or S3 returned an error. |
There is no per-request rate limit on these endpoints. The gate is your storage quota, and you hit it as a 402, not a 429. Storage is a tiered service — your tier sets a cumulative byte quota that does not reset monthly; it goes down when you delete. Your current tier and how much of it you have used show in the console. Quotas are checked before the write on both the direct upload and the presign, so you fail fast rather than after transferring a gigabyte.
Gotchas
PUT /api/v1/storage is multipart form-data, not a raw body. The file goes in a form field called file, and key, contentType and catalog are form fields too, not query parameters. In the browser, do not set Content-Type yourself: let fetch write the multipart boundary, or the parse fails with a 400.
Presign without confirm silently loses the file. The bytes land in S3, but the record stays pending, so the file does not appear in list, will not download, and the quota never moves. There is no background reconciler that notices. If your upload flow can be interrupted between step 2 and step 3, make confirm retryable — it is safe to call again for a pending upload and returns 404 once the upload is already confirmed.
Confirm the same Content-Type you signed with. The presigned URL is signed over the content type. A browser that helpfully sends application/octet-stream instead of the video/mp4 you declared gets a signature failure from S3, and the error comes from S3, not from us, so it reads as cryptic.
size at presign time is a declaration, not a limit. It is what the quota check runs against, and it is nothing more — S3 will accept a larger file. Confirm then reads the real size from S3 and bills that. Declaring small does not buy you anything except a quota that suddenly jumps.
Image optimisation rewrites your key. Pass optimisations (a JSON string, e.g. {"width":1200,"quality":80}) and an image content type, and the file is converted to WebP and stored at <your key>.webp. The key you asked for is not the key you get. Also: if the conversion throws, we store the original bytes rather than fail the upload — so check optimized in the response rather than assuming.
publicUrl is genuinely public. Anyone with that URL gets the file, no key needed, and it is cached for an hour. It also keeps working after you disable the storage service — we do not hold your content hostage. If a file must not be world-readable, do not hand out publicUrl; proxy it through your own authenticated endpoint using GET /api/v1/storage?uploadId=... instead.
Keys are sanitised. Anything outside A–Z a–z 0–9 . _ / - becomes an underscore, which is what stops path traversal. Spaces and accented characters in filenames survive as underscores, so two files that differ only in punctuation can collide and overwrite each other. Generate keys, don't pass through user filenames.
Delete is soft, quota-wise it is real. The record is marked deleted and the object is removed from S3, and your used bytes go down. The uploadId is gone for good — publicUrl starts returning 404.
catalog is your tenancy handle. It is a free-text organisational key with no meaning to us, and list filters on it exactly. Use it for a brand id, a project id, a customer id — decide the convention up front, because there is no rename.