The SDK handles large inputs automatically. This guide is for direct REST calls or a custom transport.
Limits
| Transport | Maximum canonical envelope |
|---|---|
| SDK inline request | 768 KiB (786,432 bytes) |
| Direct REST inline request, hosted | 4 MiB (4,194,304 bytes) |
| Direct REST inline request, self-hosted | 10 MiB (10,485,760 bytes) |
| Staged input | 192 MiB (201,326,592 bytes) |
Inline requests above 4 MiB are rejected before they are read, with HTTP 413
and this body. A self-hosted deployment applies the same check at 10 MiB:
{
"error": "Request body exceeds the 4 MiB inline limit. Upload the input as a staged input ...",
"code": "ERR_PAYLOAD_TOO_LARGE",
"limit_bytes": 4194304,
"docs": "/docs/hosted/large-payloads"
}Staged inputs upload directly to private Supabase Storage. They do not pass through a function body.
The staged object is the whole operation envelope, not just input:
interface CanonicalOperationExecutionEnvelope {
kind: "operation";
operation: string;
input: unknown;
options: Record<string, unknown>;
}1. Canonicalize and hash the envelope
Normalize through JSON serialization, preserve array order, sort object keys by ECMAScript lexicographic UTF-16 code-unit order, remove insignificant whitespace, and UTF-8 encode the result.
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") {
const encoded = JSON.stringify(value);
if (encoded === undefined) throw new TypeError("Value is not JSON serializable");
return encoded;
}
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
function canonicalOperationEnvelope(
operation: string,
input: unknown,
options: Record<string, unknown> = {},
): Uint8Array {
const normalized = JSON.parse(JSON.stringify({
kind: "operation",
operation,
input,
options,
}));
return new TextEncoder().encode(canonicalJson(normalized));
}
async function sha256Address(bytes: Uint8Array): Promise<`sha256:${string}`> {
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
return `sha256:${Array.from(digest, (byte) =>
byte.toString(16).padStart(2, "0")).join("")}`;
}Use this fixed vector to verify another implementation:
operation = pdf.validate
input = "AQID"
options = {}
canonical UTF-8 JSON = {"input":"AQID","kind":"operation","operation":"pdf.validate","options":{}}
byte length = 75
SHA-256 = sha256:630b2832c430aab2e463c6436e50927aa8f8d179cef1837b50e8f76a81155a52
2. Register and upload
Call POST /api/v2/execution-inputs with the canonical byte length and SHA-256
address:
{
"input_hash": "sha256:CANONICAL_ENVELOPE_SHA256",
"byte_length": 157286400
}The 201 response has direct Supabase TUS upload authority. Upload to its
endpoint with the bearer token, bucket, object name and content type, in 6 MiB
(6,291,456-byte) chunks. Authority expires after two hours. Keep upsert false;
content addresses are immutable.
3. Finalize the operation
Submit the same operation and options to its ordinary route, replacing input
with the staged identity:
{
"staged_input": {
"input_hash": "sha256:CANONICAL_ENVELOPE_SHA256",
"byte_length": 157286400
},
"options": {}
}Reuse the same Idempotency-Key on retries. Runstamp checks the object's path,
byte length, operation, input hash and options hash before it runs the operation.
Staged bodies for render and render-pack
POST /api/v2/render and /render-pack also accept staged bodies above 4 MiB.
Stage the full canonical JSON (up to 64 MiB), hash it, register and upload it as
above, then submit only the reference:
{
"staged_input": {
"input_hash": "sha256:CANONICAL_REQUEST_BODY_SHA256",
"byte_length": 12582912
}
}staged_input must be the only field. Runstamp checks the byte length and
SHA-256, then processes the object as the request body.
Upload a brand-pack template above 4 MiB
Templates up to 4 MiB can go as multipart/form-data to
POST /api/v2/brand-packs. Larger ones (up to 50 MB) use a signed upload:
POST /api/v2/brand-packs/uploadswith{"filename": "brand.pptx"}. The201response containsupload_pathandupload.signed_url.PUTthe file bytes toupload.signed_urlwithContent-Typeset toupload.content_type.POST /api/v2/brand-packswith{"upload_path": "...", "name": "Brand name", "description": "optional"}. Runstamp validates the stored file (path ownership, 50 MB limit,.pptxor.potx, content type, template ingestion) as it does for multipart, then creates the brand pack and deletes the staging file.
The dashboard uses the same flow at /api/dashboard/brand-packs/uploads.
Download a large result
GET /api/v2/executions/{id}/result returns 202 while work is running. On
success it returns 307 to the immutable result object
(application/vnd.runstamp.operation-result+json). Results can be up to 192 MiB
(201,326,592 bytes). Large artifacts and validation records also redirect with
307 to a signed storage URL.
fetch follows the redirect by default. curl needs -L, and HTTPX needs
follow_redirects=True:
curl -fsSL \
-H "Authorization: Bearer pj_live_YOUR_KEY" \
"https://runstamp.com/api/v2/executions/EXECUTION_ID/result"const response = await fetch(
"https://runstamp.com/api/v2/executions/EXECUTION_ID/result",
{ headers: { Authorization: `Bearer ${process.env.RUNSTAMP_API_KEY}` } },
);
if (!response.ok) throw new Error(`Result read failed: ${response.status}`);
const result = await response.json();import httpx
response = httpx.get(
"https://runstamp.com/api/v2/executions/EXECUTION_ID/result",
headers={"Authorization": "Bearer pj_live_YOUR_KEY"},
follow_redirects=True,
)
response.raise_for_status()
result = response.json()Don't send credentials to another origin (for example with curl's
--location-trusted). The signed storage URL is already the download authority.