STASHBAYFiles that pay

Developer API / v1

Files in.
Links out.

Upload files and get share links from your own tools.
Start with cURL. Build in your language.

POST /api/v1/uploads/init

curl 'https://stashbay.net/api/v1/uploads/init' \
  -H "Authorization: Bearer $STASHBAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"filename":"manual.pdf","sizeBytes":1048576}'

After transfer + completion

Initialize → transfer → complete. The quick start walks through every request.

Start with cURL

Generate an API key in Bay → Security, then follow these three requests. You do not need a Stashbay SDK or uploader script.

1. Get an upload URL

Send the filename and exact size in bytes. This example uses a 1 MiB file (1,048,576 bytes). Replace those values with your file's name and size. Files up to 64 MiB return single: true.

curl --fail-with-body 'https://stashbay.net/api/v1/uploads/init' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"filename":"manual.pdf","sizeBytes":1048576}'
{
  "fileId": "FILE_ID",
  "single": true,
  "strategy": "proxy",
  "uploadUrl": "/api/v1/uploads/object?fileId=FILE_ID"
}

2. Upload the file

Replace FILE_ID with fileId from step 1. Send the exact file bytes with your API key. Upload addresses are always on your Stashbay API origin.

curl --fail-with-body 'https://stashbay.net/api/v1/uploads/object?fileId=FILE_ID' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  --upload-file './manual.pdf'

3. Get the share link

Replace FILE_ID with fileId from step 1. Complete only after the transfer succeeds. The response contains shareUrl.

curl --fail-with-body 'https://stashbay.net/api/v1/uploads/complete' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"fileId":"FILE_ID"}'

Completed response

{
  "fileId": "YOUR_FILE_ID",
  "filename": "manual.pdf",
  "status": "active",
  "sizeBytes": 1048576,
  "sha256": null,
  "shareUrl": "https://stsh.in/abcdefgh"
}

Uploading a larger file? The CLI and language examples handle parts for you. For a custom client, follow the multipart flow.

Let the CLI handle the parts

Use the same command for small and large files. Stashbay chooses the part size, splits the file, uploads each part and finishes the upload. You get a share link when it is ready.

01

Install once

Requires Node.js 22 or later and npm. Install the CLI directly from Stashbay:

npm install --global 'https://stashbay.net/developers/stashbay-cli.tgz'

02

Set your API key

Generate a key in Bay → Security and set it in your terminal:

export STASHBAY_API_KEY='YOUR_API_KEY'
export STASHBAY_ORIGIN='https://stashbay.net'

03

Upload any file

Progress appears in your terminal; the share link is printed to standard output. Add --json for file metadata.

stashbay upload ./manual.pdf

The CLI keeps one part in memory at a time. It cancels pending uploads if a transfer fails; if completion is uncertain, it prints the file ID so you can check its status. Resuming after closing the process is not yet supported. Prefer a standalone script? Download the Node.js uploader and run node stashbay-upload.mjs ./manual.pdf with the same environment variables.

Use your language

Complete examples for local files, from initialization to share link. Each follows the returned upload plan, transfers one part at a time and sends every request to your Stashbay API origin. Run these on your computer or server; keep your key out of browser code.

Set these environment variables before running an example. Commands below use a Bash-compatible shell:

export STASHBAY_API_KEY='YOUR_API_KEY'
export STASHBAY_ORIGIN='https://stashbay.net'

Node.js 22 or later. No extra packages.

Save it as upload.mjs, then run

node upload.mjs ./manual.pdf
View complete JavaScript source

Source56 lines

import { open } from "node:fs/promises";
import { basename } from "node:path";

const origin = (process.env.STASHBAY_ORIGIN ?? "https://stashbay.net").replace(/\/$/, "");
const key = process.env.STASHBAY_API_KEY;
const path = process.argv[2];
if (!key || !path) throw new Error("Set STASHBAY_API_KEY and run: node upload.mjs FILE");

async function api(path, body) {
  const response = await fetch(`${origin}/api/v1${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
    redirect: "error",
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  return response.json();
}

const file = await open(path, "r");
try {
  const { size } = await file.stat();
  const plan = await api("/uploads/init", { filename: basename(path), sizeBytes: size });
  console.error(`Upload ID: ${plan.fileId}`);
  if (!Number.isSafeInteger(plan.partSize) || plan.partSize < 1 || plan.partSize > 64 * 1024 * 1024) {
    throw new Error("Unexpected part size");
  }
  const parts = [];
  for (let number = 1; number <= plan.partCount; number++) {
    const offset = (number - 1) * plan.partSize;
    const bytes = Buffer.alloc(Math.min(plan.partSize, size - offset));
    let filled = 0;
    while (filled < bytes.length) {
      const { bytesRead } = await file.read(bytes, filled, bytes.length - filled, offset + filled);
      if (!bytesRead) throw new Error("File changed during upload");
      filled += bytesRead;
    }
    const address = plan.single ? plan.uploadUrl :
      (await api("/uploads/parts", { fileId: plan.fileId, from: number, count: 1 })).parts[0].url;
    const url = new URL(address, origin);
    if (url.origin !== new URL(origin).origin || !url.pathname.startsWith("/api/v1/uploads/")) {
      throw new Error("Unexpected upload URL");
    }
    const response = await fetch(url, { method: "PUT", headers: { Authorization: `Bearer ${key}` }, body: bytes, redirect: "error" });
    if (!response.ok) throw new Error(`PUT ${response.status}: ${await response.text()}`);
    if (!plan.single) {
      const { etag } = await response.json();
      if (!etag) throw new Error("Missing part ETag");
      parts.push({ partNumber: number, etag: etag.replace(/^"|"$/g, "") });
    }
  }
  const result = await api("/uploads/complete", { fileId: plan.fileId, parts });
  console.log(result.shareUrl ?? JSON.stringify(result));
} finally {
  await file.close();
}

Python 3.9 or later. Uses the standard library; no packages to install.

Save it as upload.py, then run

python3 upload.py ./manual.pdf
View complete Python source

Source56 lines

import json
import os
import sys
from pathlib import Path
from urllib.request import Request, HTTPRedirectHandler, build_opener
from urllib.parse import urljoin, urlsplit


class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


origin = os.environ.get("STASHBAY_ORIGIN", "https://stashbay.net").rstrip("/")
key = os.environ["STASHBAY_API_KEY"]
path = Path(sys.argv[1])
client = build_opener(NoRedirect)


def api(route, body):
    request = Request(origin + "/api/v1" + route, method="POST",
                      data=json.dumps(body).encode(),
                      headers={"Authorization": "Bearer " + key,
                               "Content-Type": "application/json"})
    with client.open(request, timeout=300) as response:
        return json.load(response)


size = path.stat().st_size
with path.open("rb") as file:
    plan = api("/uploads/init", {"filename": path.name, "sizeBytes": size})
    print("Upload ID:", plan["fileId"], file=sys.stderr)
    if not 1 <= plan["partSize"] <= 64 * 1024 * 1024:
        raise ValueError("Unexpected part size")
    parts = []
    for number in range(1, plan["partCount"] + 1):
        length = min(plan["partSize"], size - (number - 1) * plan["partSize"])
        data = file.read(length)
        if len(data) != length:
            raise ValueError("File changed during upload")
        address = plan["uploadUrl"] if plan["single"] else api(
            "/uploads/parts", {"fileId": plan["fileId"], "from": number, "count": 1}
        )["parts"][0]["url"]
        url = urljoin(origin, address)
        target, base = urlsplit(url), urlsplit(origin)
        if (target.scheme, target.netloc) != (base.scheme, base.netloc) or not target.path.startswith("/api/v1/uploads/"):
            raise ValueError("Unexpected upload URL")
        headers = {"Content-Type": "application/octet-stream", "Authorization": "Bearer " + key}
        with client.open(Request(url, data=data, headers=headers, method="PUT"), timeout=300) as response:
            if not plan["single"]:
                etag = json.load(response)["etag"]
                if not etag:
                    raise ValueError("Missing part ETag")
                parts.append({"partNumber": number, "etag": etag.strip('"')})
    result = api("/uploads/complete", {"fileId": plan["fileId"], "parts": parts})
    print(result.get("shareUrl") or json.dumps(result))

C++17, libcurl development headers and nlohmann/json headers on your compiler's include path.

Save it as upload.cpp, then run

c++ -std=c++17 upload.cpp -lcurl -o upload
./upload ./manual.pdf
View complete C++ source

Source89 lines

#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
using Json = nlohmann::json;

size_t receive(char* bytes, size_t size, size_t count, void* target) {
    static_cast<std::string*>(target)->append(bytes, size * count);
    return size * count;
}

std::string request(const std::string& url, const char* method, const std::string& body,
                    const std::string& key, bool json) {
    CURL* curl = curl_easy_init();
    if (!curl) throw std::runtime_error("Cannot initialize HTTP client");
    std::string response;
    curl_slist* headers = curl_slist_append(nullptr, ("Authorization: Bearer " + key).c_str());
    headers = curl_slist_append(headers, json ? "Content-Type: application/json" : "Content-Type: application/octet-stream");
    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method);
    curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.data());
    curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(body.size()));
    curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receive);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
    auto error = curl_easy_perform(curl);
    long status = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
    curl_slist_free_all(headers);
    curl_easy_cleanup(curl);
    if (error != CURLE_OK) throw std::runtime_error(curl_easy_strerror(error));
    if (status < 200 || status >= 300) throw std::runtime_error("HTTP " + std::to_string(status) + ": " + response);
    return response;
}

int main(int argc, char** argv) {
    if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return 1;
    int result = 0;
    try {
        const char* token = std::getenv("STASHBAY_API_KEY");
        if (!token || argc < 2) throw std::runtime_error("Set STASHBAY_API_KEY and run: ./upload FILE");
        std::string key(token), origin = std::getenv("STASHBAY_ORIGIN") ? std::getenv("STASHBAY_ORIGIN") : "https://stashbay.net";
        if (!origin.empty() && origin.back() == '/') origin.pop_back();
        auto api = [&](const std::string& route, const Json& body) {
            return Json::parse(request(origin + "/api/v1" + route, "POST", body.dump(), key, true));
        };
        std::filesystem::path path(argv[1]);
        auto size = std::filesystem::file_size(path);
        std::ifstream file(path, std::ios::binary);
        if (!file) throw std::runtime_error("Cannot open file");
        auto plan = api("/uploads/init", {{"filename", path.filename().string()}, {"sizeBytes", size}});
        std::string fileId = plan.at("fileId");
        std::cerr << "Upload ID: " << fileId << '\n';
        auto partSize = plan.at("partSize").get<uint64_t>();
        if (partSize < 1 || partSize > 64 * 1024 * 1024) throw std::runtime_error("Unexpected part size");
        bool single = plan.at("single");
        Json parts = Json::array();
        for (int number = 1; number <= plan.at("partCount").get<int>(); number++) {
            auto length = std::min<uint64_t>(partSize, size - (number - 1) * partSize);
            std::string bytes(length, '\0');
            file.read(bytes.data(), static_cast<std::streamsize>(length));
            if (static_cast<uint64_t>(file.gcount()) != length) throw std::runtime_error("File changed during upload");
            std::string address = single ? plan.at("uploadUrl").get<std::string>() :
                api("/uploads/parts", {{"fileId", fileId}, {"from", number}, {"count", 1}}).at("parts").at(0).at("url").get<std::string>();
            if (address.rfind("/api/v1/uploads/", 0) != 0) throw std::runtime_error("Unexpected upload URL");
            auto response = request(origin + address, "PUT", bytes, key, false);
            if (!single) {
                std::string etag = Json::parse(response).at("etag").get<std::string>();
                if (etag.empty()) throw std::runtime_error("Missing part ETag");
                etag.erase(std::remove(etag.begin(), etag.end(), '"'), etag.end());
                parts.push_back({{"partNumber", number}, {"etag", etag}});
            }
        }
        auto complete = api("/uploads/complete", {{"fileId", fileId}, {"parts", parts}});
        std::cout << (complete.at("shareUrl").is_string() ? complete.at("shareUrl").get<std::string>() : complete.dump()) << '\n';
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        result = 1;
    }
    curl_global_cleanup();
    return result;
}

PHP 8.1 or later with the cURL extension. Run from the command line; allow enough memory for one upload part and the HTTP request buffer.

Save it as upload.php, then run

php -d memory_limit=256M upload.php ./manual.pdf
View complete PHP source

Source65 lines

<?php
declare(strict_types=1);

$origin = rtrim(getenv('STASHBAY_ORIGIN') ?: 'https://stashbay.net', '/');
$key = getenv('STASHBAY_API_KEY');
$path = $argv[1] ?? '';
if (!$key || !is_file($path)) {
    throw new RuntimeException('Set STASHBAY_API_KEY and run: php upload.php FILE');
}

function request(string $url, string $method, string $body, array $headers): string {
    $curl = curl_init($url);
    curl_setopt_array($curl, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_TIMEOUT => 300,
    ]);
    $data = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $error = curl_error($curl);
    curl_close($curl);
    if ($data === false || $status < 200 || $status >= 300) {
        throw new RuntimeException("HTTP $status: " . ($error ?: $data));
    }
    return $data;
}

function api(string $route, array $body): array {
    global $origin, $key;
    $data = request($origin . '/api/v1' . $route, 'POST', json_encode($body, JSON_THROW_ON_ERROR),
        ['Authorization: Bearer ' . $key, 'Content-Type: application/json']);
    return json_decode($data, true, 512, JSON_THROW_ON_ERROR);
}

$size = filesize($path);
$file = fopen($path, 'rb');
if ($file === false) throw new RuntimeException('Cannot open file');
try {
    $plan = api('/uploads/init', ['filename' => basename($path), 'sizeBytes' => $size]);
    fwrite(STDERR, 'Upload ID: ' . $plan['fileId'] . PHP_EOL);
    if ($plan['partSize'] < 1 || $plan['partSize'] > 64 * 1024 * 1024) throw new RuntimeException('Unexpected part size');
    $parts = [];
    for ($number = 1; $number <= $plan['partCount']; $number++) {
        $length = min($plan['partSize'], $size - ($number - 1) * $plan['partSize']);
        $bytes = stream_get_contents($file, $length);
        if ($bytes === false || strlen($bytes) !== $length) throw new RuntimeException('File changed during upload');
        $address = $plan['single'] ? $plan['uploadUrl'] : api('/uploads/parts',
            ['fileId' => $plan['fileId'], 'from' => $number, 'count' => 1])['parts'][0]['url'];
        if (!str_starts_with($address, '/api/v1/uploads/')) throw new RuntimeException('Unexpected upload URL');
        $data = request($origin . $address, 'PUT', $bytes,
            ['Authorization: Bearer ' . $key, 'Content-Type: application/octet-stream']);
        if (!$plan['single']) {
            $etag = json_decode($data, true, 512, JSON_THROW_ON_ERROR)['etag'] ?? '';
            if (!$etag) throw new RuntimeException('Missing part ETag');
            $parts[] = ['partNumber' => $number, 'etag' => trim($etag, '"')];
        }
    }
    $result = api('/uploads/complete', ['fileId' => $plan['fileId'], 'parts' => $parts]);
    echo ($result['shareUrl'] ?? json_encode($result, JSON_THROW_ON_ERROR)) . PHP_EOL;
} finally {
    fclose($file);
}

.NET 8 or later. Create a console project, then replace its Program.cs with the downloaded example. No NuGet packages required.

Save it as Upload.cs, then run

dotnet new console -n StashbayUpload
cp Upload.cs StashbayUpload/Program.cs
dotnet run --project StashbayUpload -- ./manual.pdf
View complete C# source

Source58 lines

using System;
using System.IO;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;

var origin = (Environment.GetEnvironmentVariable("STASHBAY_ORIGIN") ?? "https://stashbay.net").TrimEnd('/');
var key = Environment.GetEnvironmentVariable("STASHBAY_API_KEY") ?? throw new Exception("Set STASHBAY_API_KEY");
if (args.Length == 0) throw new Exception("Pass the file path as an argument");
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromMinutes(5) };

async Task<JsonNode> Api(string route, object body)
{
    using var request = new HttpRequestMessage(HttpMethod.Post, origin + "/api/v1" + route);
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
    request.Content = new StringContent(System.Text.Json.JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    using var response = await client.SendAsync(request);
    var text = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode) throw new Exception($"HTTP {(int)response.StatusCode}: {text}");
    return JsonNode.Parse(text)!;
}

await using var file = File.OpenRead(args[0]);
var size = file.Length;
var plan = await Api("/uploads/init", new { filename = Path.GetFileName(args[0]), sizeBytes = size });
var fileId = plan["fileId"]!.GetValue<string>();
Console.Error.WriteLine($"Upload ID: {fileId}");
var partSize = plan["partSize"]!.GetValue<int>();
if (partSize < 1 || partSize > 64 * 1024 * 1024) throw new Exception("Unexpected part size");
var single = plan["single"]!.GetValue<bool>();
var parts = new List<object>();
for (var number = 1; number <= plan["partCount"]!.GetValue<int>(); number++)
{
    var bytes = new byte[(int)Math.Min(partSize, size - (long)(number - 1) * partSize)];
    await file.ReadExactlyAsync(bytes);
    var address = single ? plan["uploadUrl"]!.GetValue<string>() :
        (await Api("/uploads/parts", new { fileId, from = number, count = 1 }))["parts"]![0]!["url"]!.GetValue<string>();
    var url = new Uri(new Uri(origin), address);
    if (url.GetLeftPart(UriPartial.Authority) != origin || !url.AbsolutePath.StartsWith("/api/v1/uploads/"))
        throw new Exception("Unexpected upload URL");
    using var request = new HttpRequestMessage(HttpMethod.Put, url);
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
    request.Content = new ByteArrayContent(bytes);
    using var response = await client.SendAsync(request);
    var text = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode) throw new Exception($"PUT {(int)response.StatusCode}: {text}");
    if (!single)
    {
        var etag = JsonNode.Parse(text)?["etag"]?.GetValue<string>();
        if (string.IsNullOrEmpty(etag)) throw new Exception("Missing part ETag");
        parts.Add(new { partNumber = number, etag = etag.Trim('"') });
    }
}
var result = await Api("/uploads/complete", new { fileId, parts });
Console.WriteLine(result["shareUrl"]?.GetValue<string>() ?? result.ToJsonString());

Java 17 or later, with Jackson 2.x jackson-databind, jackson-core and jackson-annotations JARs in lib/. In Maven projects, add com.fasterxml.jackson.core:jackson-databind and use your project's dependency management for its version. On Windows, use ; instead of : in the runtime classpath.

Save it as Upload.java, then run

javac -cp 'lib/*' Upload.java
java -cp '.:lib/*' Upload ./manual.pdf
View complete Java source

Source75 lines

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Map;
import java.util.Objects;

public class Upload {
    static final String ORIGIN = System.getenv().getOrDefault("STASHBAY_ORIGIN", "https://stashbay.net").replaceAll("/$", "");
    static final String KEY = Objects.requireNonNull(System.getenv("STASHBAY_API_KEY"), "Set STASHBAY_API_KEY");
    static final ObjectMapper JSON = new ObjectMapper();
    static final HttpClient CLIENT = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();

    static JsonNode api(String route, Object body) throws Exception {
        var request = HttpRequest.newBuilder(URI.create(ORIGIN + "/api/v1" + route))
            .timeout(Duration.ofMinutes(5))
            .header("Authorization", "Bearer " + KEY)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body))).build();
        var response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
        check(response);
        return JSON.readTree(response.body());
    }

    static void check(HttpResponse<String> response) {
        if (response.statusCode() < 200 || response.statusCode() >= 300)
            throw new IllegalStateException("HTTP " + response.statusCode() + ": " + response.body());
    }

    public static void main(String[] args) throws Exception {
        if (args.length == 0) throw new IllegalArgumentException("Pass the file path as an argument");
        var path = Path.of(args[0]);
        long size = Files.size(path);
        try (InputStream file = Files.newInputStream(path)) {
            var plan = api("/uploads/init", Map.of("filename", path.getFileName().toString(), "sizeBytes", size));
            var fileId = plan.get("fileId").asText();
            System.err.println("Upload ID: " + fileId);
            int partSize = plan.get("partSize").asInt();
            if (partSize < 1 || partSize > 64 * 1024 * 1024) throw new IllegalStateException("Unexpected part size");
            boolean single = plan.get("single").asBoolean();
            var parts = new ArrayList<Map<String, Object>>();
            for (int number = 1; number <= plan.get("partCount").asInt(); number++) {
                int length = (int) Math.min(partSize, size - (long) (number - 1) * partSize);
                byte[] bytes = file.readNBytes(length);
                if (bytes.length != length) throw new IllegalStateException("File changed during upload");
                String address = single ? plan.get("uploadUrl").asText() :
                    api("/uploads/parts", Map.of("fileId", fileId, "from", number, "count", 1))
                        .get("parts").get(0).get("url").asText();
                URI base = URI.create(ORIGIN), url = base.resolve(address);
                if (!Objects.equals(url.getScheme(), base.getScheme()) || !Objects.equals(url.getRawAuthority(), base.getRawAuthority())
                    || url.getPath() == null || !url.getPath().startsWith("/api/v1/uploads/"))
                    throw new IllegalStateException("Unexpected upload URL");
                var request = HttpRequest.newBuilder(url).timeout(Duration.ofMinutes(5))
                    .header("Authorization", "Bearer " + KEY)
                    .PUT(HttpRequest.BodyPublishers.ofByteArray(bytes)).build();
                var response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
                check(response);
                if (!single) {
                    String etag = JSON.readTree(response.body()).path("etag").asText("");
                    if (etag.isEmpty()) throw new IllegalStateException("Missing part ETag");
                    parts.add(Map.of("partNumber", number, "etag", etag.replace("\"", "")));
                }
            }
            var result = api("/uploads/complete", Map.of("fileId", fileId, "parts", parts));
            System.out.println(result.path("shareUrl").isTextual() ? result.get("shareUrl").asText() : result.toString());
        }
    }
}

Examples stop on the first error and print the upload ID to standard error. Use that ID to check status or cancel a pending upload. They do not automatically retry requests or resume after restarting. See limits and recovery before adding retry logic.

One key, your account

Send Authorization: Bearer YOUR_API_KEY on every API request. Keys authenticate your account directly; browser cookies and CSRF form tokens are not used on these endpoints. Requests from other websites are refused.

Your account has one active key. You can rotate or revoke it in Security; the previous key stops authenticating new requests immediately. The full key is shown only when it is generated.

Use production keys with https://stashbay.net/api/v1 and alpha keys with https://alpha.stashbay.in/api/v1. Alpha has separate accounts and storage.

This API grants uploads and reads of your own account and files. Returned share links follow the normal download flow; an API key does not grant ad-free downloads or payout access.

Multipart uploads

The CLI and language examples handle this automatically. When building your own client, files larger than 64 MiB return single: false. Use the returned partSize and partCount to split the bytes.

  1. 01

    Initialize

    Send the filename and exact byte size to POST /uploads/init. Keep the returned fileId and transfer plan.

  2. 02

    Transfer

    If single is true, PUT the file to uploadUrl. Otherwise fetch part URLs, split the file using partSize, and keep every part number and ETag.

  3. 03

    Complete

    Send fileId and, for multipart uploads, the parts array to POST /uploads/complete. A successful response contains shareUrl.

Request part URLs

After initialization, fetch URLs for up to 64 consecutive parts. Repeat for the remaining parts; keep their original numbers when retrying.

curl --fail-with-body --silent --show-error 'https://stashbay.net/api/v1/uploads/parts' \
  -H "Authorization: Bearer $STASHBAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"fileId":"YOUR_FILE_ID","from":1,"count":2}'

Send each part

PUT the raw bytes of each part to its returned URL with your bearer key. Send raw bytes, not multipart/form-data. The JSON response contains the part's etag; keep it with the part number.

curl --fail-with-body 'https://stashbay.net/api/v1/uploads/part?fileId=YOUR_FILE_ID&partNumber=1' \
  -H "Authorization: Bearer $STASHBAY_API_KEY" \
  --upload-file ./part-1.bin

Completing a multipart upload

POST this JSON to /api/v1/uploads/complete with your bearer key. Send every uploaded part once, numbered from 1. All parts use the returned part size except the final remainder.

{
  "fileId": "YOUR_FILE_ID",
  "parts": [
    {
      "partNumber": 1,
      "etag": "ETAG_FROM_PART_1"
    },
    {
      "partNumber": 2,
      "etag": "ETAG_FROM_PART_2"
    }
  ]
}

Endpoint reference

Paths below are relative to https://stashbay.net/api/v1. Control requests and API responses use JSON. File transfers use raw binary bodies. Example URLs and IDs are illustrative.

GET/account

Check your account

Read your storage allowance before uploading. Reserved space for pending uploads is included in storageUsedBytes. allowanceNotice appears only once your rolling 24-hour upload allowance, file count or upload slots are at least 90% used. Keys and accounts belong to one environment.

Success response · 200

{
  "userId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB",
  "username": "publisher",
  "status": "active",
  "tier": "starter",
  "storageUsedBytes": 0,
  "storageCapBytes": 1073741824,
  "storageAvailableBytes": 1073741824,
  "remoteUploadEnabled": false
}
POST/uploads/init

Start an upload

Reserve storage and receive a transfer plan. Files up to 64 MiB use a single PUT to uploadUrl; larger files use multipart uploads. Use the returned partSize and partCount. Upload addresses are relative to your API origin. allowanceNotice appears only once this upload brings your rolling 24-hour allowance, file count or upload slots to at least 90% used. Each call creates a new upload, so do not automatically retry an initialization whose response was lost.

Request body

{
  "filename": "manual.pdf",
  "sizeBytes": 1048576
}

Success response · 200

{
  "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB",
  "strategy": "proxy",
  "partSize": 1048576,
  "partCount": 1,
  "single": true,
  "uploadUrl": "/api/v1/uploads/object?fileId=01K4J7Y4H8ZQX2W3N5V6R9T0AB",
  "uploadHeaders": {},
  "partUrlTemplate": null
}
PUT/uploads/object

Transfer a small file

PUT the raw file bytes to uploadUrl with your bearer key. Do not wrap them in a form or JSON. Send the exact declared byte length. Bytes that have already arrived cannot be replaced; a repeated PUT returns 409. The completion step is still required.

fileIdquery · required
The fileId returned by initialization.

Success response · 200

{
  "etag": "returned-object-etag"
}
POST/uploads/parts

Get multipart URLs

Request between 1 and 64 consecutive part URLs. Part numbers start at 1 and cannot exceed the plan's partCount. Request another batch as needed; part URLs stay valid while the upload is pending.

Request body

{
  "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB",
  "from": 1,
  "count": 2
}

Success response · 200

{
  "parts": [
    {
      "partNumber": 1,
      "url": "/api/v1/uploads/part?fileId=01K4J7Y4H8ZQX2W3N5V6R9T0AB&partNumber=1"
    },
    {
      "partNumber": 2,
      "url": "/api/v1/uploads/part?fileId=01K4J7Y4H8ZQX2W3N5V6R9T0AB&partNumber=2"
    }
  ]
}
PUT/uploads/part

Transfer a part

PUT the raw bytes of one part to its returned URL with your bearer key. Each part must equal partSize except the final remainder. Keep each partNumber and the etag from the JSON response. A failed part can be uploaded again with the same number.

fileIdquery · required
The fileId returned by initialization.
partNumberquery · required
A part number from the upload plan.

Success response · 200

{
  "partNumber": 1,
  "etag": "returned-part-etag"
}
POST/uploads/complete

Complete and get a share link

Call after all bytes have arrived. Multipart uploads require all partNumber/etag pairs in parts; single uploads can omit parts. File screening runs before publication. Repeating completion for an active file returns its current share link without uploading again. shareUrl is the normal download page and may be null if its link has been disabled.

Request body

{
  "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB"
}

Success response · 200

{
  "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB",
  "filename": "manual.pdf",
  "status": "active",
  "sizeBytes": 1048576,
  "sha256": null,
  "shareUrl": "https://stsh.in/abcdefgh"
}

Optional fields: sha256 (64 hexadecimal characters) and archivePassword for encrypted ZIP inspection. The password is not a download password. See the OpenAPI schema for the full request.

GET/files/{fileId}

Check file status

Read only your own file's status and existing share link. Pending files are not downloadable. If completion's response was lost, check here before starting over. When status is active, repeating completion safely recovers or creates the share link.

fileIdpath · required
The fileId returned by initialization.

Success response · 200

{
  "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB",
  "filename": "manual.pdf",
  "status": "active",
  "sizeBytes": 1048576,
  "sha256": null,
  "shareUrl": "https://stsh.in/abcdefgh"
}
POST/uploads/abort

Cancel a pending upload

Cancel a pending upload and release its storage reservation. This does not delete a published file. A second abort returns 409 because the upload is no longer pending.

Request body

{
  "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB"
}

Success response · 200

{
  "ok": true
}
POST/uploads/remote

Import from a URL

Available only when account.remoteUploadEnabled is true. Submit a publicly reachable HTTP(S) file URL up to 20 GiB; the source is validated and the import runs asynchronously. An account can have 2 pending imports and submit 20 imports totaling 50 GiB in a rolling 24 hours. Submissions are limited to 3 per minute; a full shared import queue also returns 429. Private addresses and Stashbay source URLs are refused. Do not automatically retry this POST: it creates another import.

Request body

{
  "url": "https://example.com/manual.pdf",
  "filename": "manual.pdf"
}

Success response · 200

{
  "remoteUploadId": "01K4J7Y4H8ZQX2W3N5V6R9T0AC",
  "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB",
  "filename": "manual.pdf",
  "sizeBytes": 1048576
}
GET/uploads/remote

Check remote import progress

Pass 1–25 comma-separated remoteUploadIds in ids. Only jobs belonging to your account are returned. Poll no more than once every five seconds. Once done, call completion with its fileId to obtain a share link; do not complete a queued or fetching import.

idsquery · required
Comma-separated remoteUploadIds; maximum 25.

Success response · 200

{
  "jobs": [
    {
      "id": "01K4J7Y4H8ZQX2W3N5V6R9T0AC",
      "fileId": "01K4J7Y4H8ZQX2W3N5V6R9T0AB",
      "status": "fetching",
      "bytesFetched": 524288,
      "declaredSizeBytes": 1048576,
      "lastError": null
    }
  ]
}

Limits and recovery

Storage
your account allowance applies to API uploads. Space is reserved during initialization and refunded when you abort a pending upload.
Requests
240 API requests per minute per account, including at most 60 reads. Initialization, completion and cancellation each allow 30 per minute. These burst limits can vary by service location. Honor 429 and Retry-After.
Upload allowance
10 unfinished uploads at once; 500 files and 100 GiB of declared file sizes per rolling 24 hours. These limits are shared across your API keys and browser uploads. Cancelled, failed and deleted uploads still count toward the daily allowance.
URL imports
3 submissions per minute, 2 unfinished imports, 20 imports and 50 GiB per rolling 24 hours; at most 20 GiB per file. Imports also count toward your upload allowance. A full shared import queue returns 429. Private addresses and Stashbay URLs are refused.
Multipart
at most 64 URLs per request and 10,000 parts per file. Use the server's part size. Keep concurrency modest; four in-flight parts is a useful starting point.
Upload addresses
every file and part URL is on your Stashbay API origin and needs your bearer key. Part URLs stay usable while the upload is pending. Each PUT must match the expected byte length, and received single-file bytes cannot be overwritten. Rotating or revoking your key stops them working immediately.
Retries
retry a failed multipart part using its original number. If a single-file PUT returns 409, bytes may already have arrived; try completion, or cancel and start again. Persist the plan and ETags if your tool needs to resume. For a lost completion response, check file status and repeat completion if active. Do not blindly repeat initialization or remote submission.
Abandoned uploads
pending uploads older than 24 hours are eligible for cleanup. Abort promptly when cancelling instead of waiting for cleanup.
File policy
the same content rules apply as in Bay. Files are inspected before publication; changing the extension does not bypass screening.

Errors you can act on

The HTTP status carries the outcome. API errors have an error string; edge errors may have a different body.

{
  "error": "Provide a valid API key in the Authorization: Bearer header."
}
400
Invalid JSON, fields, part numbers or byte lengths. Correct the request before retrying.
401
Missing, invalid, rotated or revoked API key. Send a current bearer key.
403
Inactive account, cross-site request, insufficient permission, or remote imports disabled.
404
Unknown endpoint, file or upload; another account's files also return 404.
405
Wrong HTTP method for an upload endpoint. Check the Allow response header.
409
Insufficient storage, a closed upload, missing bytes or an upload conflict. Inspect the file status.
413
JSON request exceeds 1 MiB. File bytes belong in the separate PUT requests.
415
Wrong request content type, unsupported file format, or file screening refused the upload.
451
File matches a takedown restriction and cannot be hosted.
429
Request rate, pending uploads or rolling daily allowance reached, or the import queue is full. Honor Retry-After; daily allowances may require a longer wait.
500
Unexpected server failure. Check upload status before retrying a state-changing request.
503
File inspection or a dependency is unavailable. Follow the response's recovery instructions.

Need help? Contact Stashbay with the error message. Keep your API key out of support messages.