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. Initialize the upload

Set your key and file path. This quick start uses a file up to 64 MiB; its exact byte size is read automatically. Run the steps in the same Bash terminal with cURL and jq installed.

set -euo pipefail
export STASHBAY_API_KEY='YOUR_API_KEY'
export STASHBAY_ORIGIN='https://stashbay.net'
FILE='./manual.pdf'
SIZE=$(wc -c < "$FILE" | tr -d ' ')
if [ "$SIZE" -lt 1 ] || [ "$SIZE" -gt 67108864 ]; then
  echo 'Choose a nonempty file up to 64 MiB, or use the multipart examples.' >&2
  exit 1
fi

PLAN=$(curl --fail-with-body --silent --show-error \
  "$STASHBAY_ORIGIN/api/v1/uploads/init" \
  -H "Authorization: Bearer $STASHBAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data "$(jq -n --arg filename "$(basename "$FILE")" \
    --argjson size "$SIZE" '{filename: $filename, sizeBytes: $size}')")

echo "$PLAN" | jq .
FILE_ID=$(echo "$PLAN" | jq -er '.fileId')

2. Transfer the file

PUT the raw file to the returned URL. A signed URL authorizes the transfer itself; send your key only when the strategy is proxy and the URL is a Stashbay API path.

UPLOAD_URL=$(echo "$PLAN" | jq -er '.uploadUrl')
STRATEGY=$(echo "$PLAN" | jq -er '.strategy')
AUTH=(-H 'Content-Type: application/octet-stream')
case "$STRATEGY:$UPLOAD_URL" in
  proxy:/api/v1/uploads/*)
    UPLOAD_URL="$STASHBAY_ORIGIN$UPLOAD_URL"
    AUTH+=(-H "Authorization: Bearer $STASHBAY_API_KEY") ;;
  presigned:https://*) ;;
  *) echo 'Unexpected upload URL or strategy' >&2; exit 1 ;;
esac

curl --fail-with-body --silent --show-error \
  "${AUTH[@]}" --upload-file "$FILE" "$UPLOAD_URL"

3. Complete and get the share link

Completion checks the uploaded file and publishes it. Read shareUrl from the response; transferring the bytes alone does not finish the upload.

curl --fail-with-body --silent --show-error \
  "$STASHBAY_ORIGIN/api/v1/uploads/complete" \
  -H "Authorization: Bearer $STASHBAY_API_KEY" \
  -H 'Content-Type: application/json' \
  --data "$(jq -n --arg id "$FILE_ID" '{fileId: $id}')" \
  | jq .

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 handles both proxy and signed URLs. 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

Source59 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);
    const headers = {};
    if (plan.strategy === "proxy" && url.origin === new URL(origin).origin) {
      headers.Authorization = `Bearer ${key}`;
    } else if (plan.strategy !== "presigned" || url.protocol !== "https:") {
      throw new Error("Unexpected upload URL or strategy");
    }
    const response = await fetch(url, { method: "PUT", headers, body: bytes, redirect: "error" });
    if (!response.ok) throw new Error(`PUT ${response.status}: ${await response.text()}`);
    if (!plan.single) {
      const etag = plan.strategy === "proxy" ? (await response.json()).etag : response.headers.get("etag");
      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

Source58 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)
        headers = {"Content-Type": "application/octet-stream"}
        if plan["strategy"] == "proxy" and (target.scheme, target.netloc) == (base.scheme, base.netloc):
            headers["Authorization"] = "Bearer " + key
        elif plan["strategy"] != "presigned" or target.scheme != "https":
            raise ValueError("Unexpected upload URL or strategy")
        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 plan["strategy"] == "proxy" else response.headers.get("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

Source113 lines

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

struct Response { std::string body, etag; };

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;
}

size_t header(char* bytes, size_t size, size_t count, void* target) {
    std::string line(bytes, size * count), name = line.substr(0, 5);
    std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); });
    if (name == "etag:") {
        auto value = line.substr(5);
        auto first = value.find_first_not_of(" \t\r\n\"");
        auto last = value.find_last_not_of(" \t\r\n\"");
        *static_cast<std::string*>(target) = first == std::string::npos ? "" : value.substr(first, last - first + 1);
    }
    return size * count;
}

Response request(const std::string& url, const char* method, const std::string& body,
                 const std::string& key = "", bool json = false) {
    CURL* curl = curl_easy_init();
    if (!curl) throw std::runtime_error("Cannot initialize HTTP client");
    Response response;
    curl_slist* headers = nullptr;
    if (!key.empty()) headers = curl_slist_append(headers, ("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.body);
    curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, header);
    curl_easy_setopt(curl, CURLOPT_HEADERDATA, &response.etag);
    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.body);
    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).body);
        };
        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"), strategy = plan.at("strategy");
        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>();
            std::string url, auth;
            if (strategy == "proxy" && address.rfind("/api/v1/uploads/", 0) == 0) {
                url = origin + address;
                auth = key;
            } else if (strategy == "presigned" && address.rfind("https://", 0) == 0) {
                url = address;
            } else throw std::runtime_error("Unexpected upload URL or strategy");
            auto response = request(url, "PUT", bytes, auth);
            if (!single) {
                std::string etag = strategy == "proxy" ? Json::parse(response.body).at("etag").get<std::string>() : response.etag;
                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

Source77 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): array {
    $etag = null;
    $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,
        CURLOPT_HEADERFUNCTION => function ($curl, string $line) use (&$etag): int {
            if (stripos($line, 'etag:') === 0) $etag = trim(substr($line, 5), " \t\r\n\"");
            return strlen($line);
        },
    ]);
    $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, $etag];
}

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'];
        $headers = ['Content-Type: application/octet-stream'];
        if ($plan['strategy'] === 'proxy' && str_starts_with($address, '/api/v1/uploads/')) {
            $url = $origin . $address;
            $headers[] = 'Authorization: Bearer ' . $key;
        } elseif ($plan['strategy'] === 'presigned' && parse_url($address, PHP_URL_SCHEME) === 'https') {
            $url = $address;
        } else {
            throw new RuntimeException('Unexpected upload URL or strategy');
        }
        [$data, $etag] = request($url, 'PUT', $bytes, $headers);
        if (!$plan['single']) {
            if ($plan['strategy'] === 'proxy') $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

Source60 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 strategy = plan["strategy"]!.GetValue<string>();
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);
    using var request = new HttpRequestMessage(HttpMethod.Put, url);
    if (strategy == "proxy" && url.GetLeftPart(UriPartial.Authority) == origin)
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
    else if (strategy != "presigned" || url.Scheme != "https")
        throw new Exception("Unexpected upload URL or strategy");
    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 = strategy == "proxy" ? JsonNode.Parse(text)?["etag"]?.GetValue<string>() : response.Headers.ETag?.Tag;
        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

Source78 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();
            String strategy = plan.get("strategy").asText();
            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);
                var request = HttpRequest.newBuilder(url).timeout(Duration.ofMinutes(5));
                if (strategy.equals("proxy") && Objects.equals(url.getScheme(), base.getScheme())
                    && Objects.equals(url.getRawAuthority(), base.getRawAuthority()))
                    request.header("Authorization", "Bearer " + KEY);
                else if (!strategy.equals("presigned") || !url.getScheme().equals("https"))
                    throw new IllegalStateException("Unexpected upload URL or strategy");
                var response = CLIENT.send(request.PUT(HttpRequest.BodyPublishers.ofByteArray(bytes)).build(),
                    HttpResponse.BodyHandlers.ofString());
                check(response);
                if (!single) {
                    String etag = strategy.equals("proxy") ? JSON.readTree(response.body()).path("etag").asText("") :
                        response.headers().firstValue("ETag").orElse("");
                    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 bytes according to the strategy

Returned strategyWhere to PUTAuthentication on the PUT
proxyResolve the relative URL against your API origin.Include your Stashbay bearer key.
presignedUse the returned absolute storage URL exactly.The URL already authorizes the PUT. Do not send your Stashbay key.

Use curl --upload-file ./manual.pdf 'UPLOAD_URL' for a signed URL. Add your bearer header only for a proxy URL on your Stashbay origin. Send raw bytes, not multipart/form-data. Signed PUT responses may have an empty body; read the ETag header for multipart uploads.

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. 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; larger files use multipart uploads. Follow the returned strategy, partSize and partCount. 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",
  "partUrlTemplate": null,
  "expiresIn": null
}
PUT/uploads/object

Transfer a small file

For strategy=proxy, PUT the raw file bytes to uploadUrl with your bearer key. Do not wrap them in a form or JSON. For strategy=presigned, PUT to the returned absolute URL instead, without your Stashbay key. Both strategies require the completion step.

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; repeating this call refreshes signed URLs for the same pending upload.

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

For proxy uploads, send raw bytes and your bearer key. For presigned uploads, use the returned absolute URL without the key. Each part must equal partSize except the final remainder. Keep each partNumber and ETag; for presigned PUTs, read the ETag response header and remove surrounding quotes. 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. 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
Allow for 30 initializations, 30 completions, 30 aborts and 30 remote submissions per minute per account, with separate counters. API traffic also has a 1,200-request per minute account budget. Enforcement can vary by service location. Honor 429 and Retry-After.
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.
Signed URLs
Valid for six hours. Refresh multipart URLs through /uploads/parts. If a single-file URL expires, abort and initialize again. Already-issued signed URLs remain valid until expiry even after API-key rotation.
Retries
Retry a failed part using its original number. 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 or storage 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. Proxy PUT routes return 404 in presigned mode.
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
Too many requests. Wait at least Retry-After seconds before retrying.
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.