# Uploads

Deploying without a repository takes three calls and one `PUT`. The archive goes straight to storage over a signed URL, so it never passes through the API.

1. `POST /api/upload/request-url` — get a signed URL and an `uploadId`
2. `PUT` the zip to that URL
3. `POST /api/upload/complete` — tell the platform the bytes landed
4. [`POST /api/applications/create-from-upload`](/reference/api/applications#create-from-an-upload) — build and deploy it

> [!TIP]
> Zip the contents of the project directory, not the directory itself. A single top-level folder inside the archive puts every path one level deeper than the build expects.

## Request an upload URL

```endpoint
POST /api/upload/request-url
> Reserves an upload and returns a signed URL valid for 15 minutes. `maxSize` is 100 MB by default; passing a larger `fileSize` is rejected before you waste the transfer.
permission: create:projects

param fileName | string | source.zip | Name recorded for the archive
param contentType | string | application/zip | Must match the Content-Type you PUT with
param fileSize | number | optional | Bytes. Checked against maxSize up front
param targetOrganisationId | string | session only | Required when using a session token

response 200 | Signed URL and upload id
{
  "uploadId": "upl_8Xa1",
  "signedUrl": "https://storage.googleapis.com/lc-uploads/...",
  "gcsPath": "uploads/org_1Ab/upl_8Xa1/source.zip",
  "expiresAt": "2026-08-28T07:15:00.000Z",
  "maxSize": 104857600
}

code curl
curl -X POST https://api.light-cloud.com/api/upload/request-url \
  -H "Authorization: Bearer $LIGHT_CLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"fileSize\": $(wc -c < source.zip)}"

code Node
import { stat } from "node:fs/promises";

const { size } = await stat("source.zip");

const res = await fetch("https://api.light-cloud.com/api/upload/request-url", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_CLOUD_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ fileSize: size }),
});

if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

const { uploadId, signedUrl } = await res.json();

code Python
import os
import requests

res = requests.post(
    "https://api.light-cloud.com/api/upload/request-url",
    headers={"Authorization": f"Bearer {os.environ['LIGHT_CLOUD_API_KEY']}"},
    json={"fileSize": os.path.getsize("source.zip")},
)
res.raise_for_status()

upload = res.json()
upload_id, signed_url = upload["uploadId"], upload["signedUrl"]
```

## Upload the archive

The `PUT` goes to the signed URL, not to the API. **No `Authorization` header** — the signature is the authorisation. The `Content-Type` must match what you asked for, or the signature will not verify.

```codetabs
### curl
curl -X PUT "$SIGNED_URL" \
  -H "Content-Type: application/zip" \
  --upload-file source.zip

### Node
import { readFile } from "node:fs/promises";

const upload = await fetch(signedUrl, {
  method: "PUT",
  headers: { "Content-Type": "application/zip" },
  body: await readFile("source.zip"),
});

if (!upload.ok) throw new Error(`Upload failed: ${upload.status}`);

### Python
import requests

with open("source.zip", "rb") as handle:
    res = requests.put(
        signed_url,
        data=handle,
        headers={"Content-Type": "application/zip"},
    )
res.raise_for_status()

### Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;

var put = HttpRequest.newBuilder()
    .uri(URI.create(signedUrl))
    .header("Content-Type", "application/zip")
    .PUT(HttpRequest.BodyPublishers.ofFile(Path.of("source.zip")))
    .build();

HttpClient.newHttpClient().send(put, HttpResponse.BodyHandlers.discarding());
```

## Complete the upload

```endpoint
POST /api/upload/complete
> Confirms the bytes landed and records what the archive is. The detection fields are hints carried through to the create call — leave them out and the platform inspects the archive itself.
permission: create:projects

param uploadId | string | required | From request-url
param detectedFramework | string | optional | What you believe this is
param detectedRuntime | string | optional | Container only
param detectedDeploymentType | string | optional | `static` or `container`
param detectedBuildCommand | string | optional | Command that produces the build
param detectedOutputDirectory | string | optional | Static only
param targetOrganisationId | string | session only | Required when using a session token

response 200 | Upload ready to build from
{
  "uploadId": "upl_8Xa1",
  "status": "ready",
  "size": 4823910
}

code curl
curl -X POST https://api.light-cloud.com/api/upload/complete \
  -H "Authorization: Bearer $LIGHT_CLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"uploadId": "upl_8Xa1", "detectedDeploymentType": "static"}'

code Node
const res = await fetch("https://api.light-cloud.com/api/upload/complete", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_CLOUD_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ uploadId, detectedDeploymentType: "static" }),
});

if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

code Python
import os
import requests

requests.post(
    "https://api.light-cloud.com/api/upload/complete",
    headers={"Authorization": f"Bearer {os.environ['LIGHT_CLOUD_API_KEY']}"},
    json={"uploadId": upload_id, "detectedDeploymentType": "static"},
).raise_for_status()
```

## Clean up abandoned uploads

```endpoint
POST /api/upload/cleanup
> Discards uploads that were reserved but never completed.
permission: create:projects

param targetOrganisationId | string | session only | Required when using a session token

response 200 | How many were discarded
{ "removed": 3 }

code curl
curl -X POST https://api.light-cloud.com/api/upload/cleanup \
  -H "Authorization: Bearer $LIGHT_CLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## The whole flow

```codetabs
### curl
#!/usr/bin/env bash
set -euo pipefail

API=https://api.light-cloud.com
AUTH=(-H "Authorization: Bearer $LIGHT_CLOUD_API_KEY" -H "Content-Type: application/json")

(cd dist && zip -qr ../source.zip .)

upload=$(curl -sS -X POST "$API/api/upload/request-url" "${AUTH[@]}" \
  -d "{\"fileSize\":$(wc -c < source.zip)}")

UPLOAD_ID=$(echo "$upload" | jq -r .uploadId)
SIGNED_URL=$(echo "$upload" | jq -r .signedUrl)

curl -sS -X PUT "$SIGNED_URL" \
  -H "Content-Type: application/zip" \
  --upload-file source.zip

curl -sS -o /dev/null -X POST "$API/api/upload/complete" "${AUTH[@]}" \
  -d "{\"uploadId\":\"$UPLOAD_ID\",\"detectedDeploymentType\":\"static\"}"

curl -sS -X POST "$API/api/applications/create-from-upload" "${AUTH[@]}" \
  -d "{
    \"name\": \"marketing-site\",
    \"uploadId\": \"$UPLOAD_ID\",
    \"deploymentType\": \"static\",
    \"outputDirectory\": \".\"
  }"

### Node
import { readFile, stat } from "node:fs/promises";

const API = "https://api.light-cloud.com";
const headers = {
  Authorization: `Bearer ${process.env.LIGHT_CLOUD_API_KEY}`,
  "Content-Type": "application/json",
};

const post = async (path, body) => {
  const res = await fetch(API + path, {
    method: "POST",
    headers,
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();
};

const { size } = await stat("source.zip");

// 1. Signed URL
const { uploadId, signedUrl } = await post("/api/upload/request-url", {
  fileSize: size,
});

// 2. Straight to storage — no Authorization header here
const put = await fetch(signedUrl, {
  method: "PUT",
  headers: { "Content-Type": "application/zip" },
  body: await readFile("source.zip"),
});
if (!put.ok) throw new Error(`Upload failed: ${put.status}`);

// 3. Confirm
await post("/api/upload/complete", {
  uploadId,
  detectedDeploymentType: "static",
});

// 4. Build and deploy
const app = await post("/api/applications/create-from-upload", {
  name: "marketing-site",
  uploadId,
  deploymentType: "static",
  outputDirectory: ".",
});

console.log(app.id);

### Python
import os
import requests

API = "https://api.light-cloud.com"
headers = {"Authorization": f"Bearer {os.environ['LIGHT_CLOUD_API_KEY']}"}

def post(path, **body):
    res = requests.post(API + path, headers=headers, json=body)
    res.raise_for_status()
    return res.json()

# 1. Signed URL
upload = post("/api/upload/request-url", fileSize=os.path.getsize("source.zip"))

# 2. Straight to storage — no auth header
with open("source.zip", "rb") as handle:
    requests.put(
        upload["signedUrl"],
        data=handle,
        headers={"Content-Type": "application/zip"},
    ).raise_for_status()

# 3. Confirm
post(
    "/api/upload/complete",
    uploadId=upload["uploadId"],
    detectedDeploymentType="static",
)

# 4. Build and deploy
app = post(
    "/api/applications/create-from-upload",
    name="marketing-site",
    uploadId=upload["uploadId"],
    deploymentType="static",
    outputDirectory=".",
)

print(app["id"])
```

## Related

- [Applications](/reference/api/applications): Creating an application from the upload.
- [Deploy from an upload](/create/upload): The same flow in the console.
- [Frameworks](/platform/frameworks): What detection recognises.
