Skip to content
Hoody.com

The Hoody Pipe service streams data between containers and external clients over HTTP. A sender (POST/PUT) and one or more receivers (GET) connect on a shared path, and the sender’s request body is forwarded to every receiver in real time with no server-side storage. This page covers the browser-facing web interface, the service information endpoints (help, health, metrics), and the data transfer operations used to push and pull pipe payloads.

Browser-facing HTML pages for sending and receiving data without writing code. They are browser pages, so they are not part of the published API specification and the SDK has no method that fetches them. Build their URLs with client.embeds.pipe.send(...) and client.embeds.pipe.noscript(...) instead; see Embed a kit UI.

Send page. Returns the send page, an HTML page for sending one file or a text to a pipe from the browser. You can choose or drop a file, type or paste a text, or paste an image, which is sent as a file. The page fills in a random pipe name that you can edit, and you can set the receiver count. While it sends, it shows upload progress, how many receivers are connected and the transfer state (from ?progress, so it takes no receiver slot). It also shows a link for the receiver, with Copy and a QR code, to /api/v1/pipe/<name>?receive (plus &n= for several receivers). Cancel stops the send and frees the name. A name with a . or .. segment (also %2e-escaped) is refused before sending, because the browser would resolve it to another pipe. The page reports the send as delivered only when the response ends with [INFO] Transfer complete.. Each [INFO] A receiver disconnected. after [INFO] Streaming to … counts as a receiver that did not get the data (Delivered to 1 of 2 receivers (1 disconnected).). The file name (or the filename given for a text or pasted image) reaches receivers in Content-Disposition. The page loads nothing from other origins, sends a nonce-based Content-Security-Policy, and stores nothing in the browser. Every control can be pre-filled from the query parameters below.

Bare /api/v1/pipe and /api/v1/pipe/ serve the same page. It sends to /api/v1/pipe/<name> and links to the no-JavaScript page at /api/v1/pipe/noscript, whichever of these URLs (or the root) it was opened at.

Also accessible at / (root alias).

NameInTypeRequiredDescription
namequerystringNoPre-fills the pipe name (up to 1024 characters). Absent → a random name.
nqueryintegerNoPre-fills the receiver count (1-256). Invalid → 1. Default: 1.
textquerystringNoPre-fills the text to send (up to 100000 characters) and selects text mode.
modequerystringNoSelects file or text mode. Default file, or text when text is given. Allowed values: file, text.
filenamequerystringNoPre-fills the file name used for a text or pasted send (up to 255 characters).
autostartquerystringNoText mode only — 1, true, yes or bare sends the pre-filled text without a click once the page has loaded (needs a text; a name the page refuses shows the reason instead). A file send always needs the user to pick the file. Allowed values: 1, true, yes, 0, false, no.

This endpoint accepts no request body.

Returns the send page as HTML, with a nonce-based Content-Security-Policy header (default-src 'none'; script and style only by per-response nonce; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'). The page loads nothing from other origins and stores nothing in the browser.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hoody Pipe — Send</title>
</head>
<body>
<main id="app"></main>
</body>
</html>
Terminal window
# Open the send page with a pre-filled name and receiver count
curl "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/?name=transfer123&n=2"
# Open in text mode with the text pre-filled and sent on load
curl "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/?mode=text&text=hello&filename=note.txt&autostart=true"

Returns a pure HTML form for file or text upload that works without JavaScript. Useful in restricted browser environments or when JavaScript is disabled. Also accessible at /noscript.

NameInTypeRequiredDescription
pathquerystringNoPre-fill the pipe path. Only URL-safe characters allowed.
modequerystringNoInput mode: file for file picker, text for textarea. Default: "file". Allowed values: file, text.
waitqueryintegerNoSeconds the upload waits for its receivers (1-3600), forwarded to the form’s POST. An invalid value is dropped.
sha256querystringNo1, true, yes or bare: the upload is hashed with SHA-256, forwarded to the form’s POST. 0, false, no or absent: not hashed.

This endpoint accepts no request body.

Returns the HTML upload form with a CSP nonce and the optional path pre-filled.

<!DOCTYPE html>
<html>
<head><title>Hoody Pipe — Upload</title></head>
<body>
<form method="POST" enctype="multipart/form-data" action="/api/v1/pipe/mypath">
<input type="file" name="file" />
<button type="submit">Send</button>
</form>
</body>
</html>
Terminal window
curl "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/noscript?path=mypath&mode=file"

Plain-text usage instructions, the standardized health endpoint, and Prometheus metrics.

Returns plain text usage instructions showing how to send and receive data using curl. The help text includes the server’s own URL (derived from the Host header) so examples can be copied and run directly. Sections cover receiving data, sending files, text, and directories with curl -T, the ?download and ?filename controls, ?video browser playback, ?progress transfer monitoring, and end-to-end encryption with OpenSSL. Also accessible at /help.

This endpoint takes no parameters.

Returns usage instructions as text/plain.

Hoody Pipe 1.6.1
Streaming Data Transfer over HTTP
======= Get =======
curl https://pipe.example.com/mypath
======= Send (file) =======
curl -T myfile https://pipe.example.com/mypath
======= Send (text) =======
echo 'hello' | curl -T - https://pipe.example.com/mypath
======= Send (directory) =======
tar czf - ./mydir | curl -T - https://pipe.example.com/mydir.tar.gz
Terminal window
curl https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/help

Returns the standardized 9-field health response. Unauthenticated. Only reachable at /api/v1/pipe/health — a bare /health returns 404.

This endpoint takes no parameters.

Service is healthy. Returns the pipe_HealthResponse object.

{
"status": "ok",
"service": "hoody-pipe",
"built": "2026-01-15T10:30:00.000Z",
"started": "2026-01-20T14:22:10.000Z",
"memory": {
"rss": 45154304,
"heap": 7340032
},
"fds": 18,
"pid": 17,
"ip": "10.0.0.42",
"userAgent": "HoodyClient/1.0"
}
FieldTypeRequiredDescription
statusstringYesHealth state. Always ok when reachable.
servicestringYesService identifier.
builtstringNoModule mtime as ISO 8601 string.
startedstringYesProcess start time as ISO 8601 string.
memoryobjectNoResident set size and V8 heap usage in bytes.
fdsintegerNoOpen file descriptor count.
pidintegerYesProcess ID.
ipstringYesContainer IP address.
userAgentstringNoUser-Agent of the requesting client.
Terminal window
curl https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/health

Counters and gauges in the Prometheus text exposition format 0.0.4, for a scraper or a quick curl. Unauthenticated, like health; the Source IP Guard answers 403 here as on every route. Families are named hoody_pipe_* and carry only fixed labels, never a pipe name or a client IP:

  • hoody_pipe_build_info{version} (gauge, always 1) and hoody_pipe_start_time_seconds (gauge)
  • hoody_pipe_pipes_active{state="waiting|streaming"} (gauge): pipes waiting for a counterpart and pipes streaming (each capped at 1000)
  • hoody_pipe_spectators_active (gauge): open ?progress streams
  • hoody_pipe_transfers_total{result="completed|failed"} (counter): transfers that started streaming, one per transfer whatever n is
  • hoody_pipe_transferred_bytes_total (counter): bytes read from senders, counted once (not per receiver), including the part of a transfer that later failed
  • hoody_pipe_hashed_bytes_total (counter): bytes hashed for ?sha256
  • hoody_pipe_rejections_total{reason} (counter): pending_cap and spectator_cap (one per refused request), active_cap (one per refused pipe), wait_timeout (one per sender or receiver whose wait passed), idle_timeout (one per transfer ended by the 5-minute idle limit), ws_cap (one per refused ?ws request), live_cap (one per refused live request)
  • hoody_pipe_ws_pairs_active{state="waiting|paired"} (gauge) and hoody_pipe_ws_relayed_bytes_total (counter): ?ws pairs and the message payload bytes they relayed, both directions
  • hoody_pipe_live_viewer_joins_total (counter) and hoody_pipe_live_viewers_cut_total{reason="overflow|stall"} (counter): live viewers counted at their first byte, and those cut off for falling behind (overflow) or for taking nothing for 60 s (stall)

Only reachable at /api/v1/pipe/metrics. A bare /metrics returns 404 with a body stating that '/metrics' is not a valid path and pointing at '/api/v1/pipe/metrics', so a scraper pointed at the root fails at once instead of waiting as a receiver. Both are matched like health (case-insensitive, one trailing / or . allowed). HEAD returns the headers only; OPTIONS returns 204 like health; other methods return 405 with Allow: GET, HEAD, OPTIONS. Responses carry Cache-Control: no-store.

This endpoint takes no parameters. This endpoint accepts no request body.

Counters and gauges in the Prometheus text exposition format 0.0.4 (text/plain). Responses carry Cache-Control: no-store.

# HELP hoody_pipe_transfers_total Transfers that started streaming, by how they ended.
# TYPE hoody_pipe_transfers_total counter
hoody_pipe_transfers_total{result="completed"} 12
hoody_pipe_transfers_total{result="failed"} 1
Terminal window
curl https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/metrics

The pipe transfer operations. GET blocks until a sender connects; POST and PUT stream the request body to receivers; OPTIONS answers CORS preflight; HEAD with ?status returns the status snapshot headers.

Receive data from the specified pipe path. The request blocks until a sender connects and starts streaming. Once established, the response body contains the sender’s data with original headers forwarded.

Lifecycle: the receiver GETs a path and the request blocks. When a sender POSTs or PUTs to the same path with matching n, the pipe establishes and the response begins streaming. The response completes when the sender finishes uploading.

Forwarded headers from the sender:

  • Content-Type — the sender’s content type. Dangerous types (HTML, SVG, JavaScript, XHTML, XML) are rewritten to text/plain. Parameters are dropped except for a safe charset (utf-8, us-ascii, iso-8859-1).
  • Content-Length — only when the sender provided a valid ^\d{1,19}$ value AND the body is non-multipart (multipart parts use chunked encoding).
  • Content-Disposition — if provided (e.g. attachment; filename="report.pdf"). A sender-supplied inline is upgraded to attachment unless the effective Content-Type is on the inline-safe allowlist (image, audio, video, text/plain, application/pdf, excluding image/svg+xml).
  • X-Piping — custom metadata from the sender.
  • X-Hoody-Pipe — custom metadata from the sender.

Forwarded headers are CRLF-sanitized (Content-Disposition, X-Piping, X-Hoody-Pipe) to prevent header injection.

Security headers on every response: X-Robots-Tag: none, X-Content-Type-Options: nosniff, X-Hoody-Pipe-Transfer-Id, and CORS headers reflecting the receiver’s Origin. Live viewers get X-Hoody-Pipe-Live: 1 and Cache-Control: no-store instead of X-Hoody-Pipe-Transfer-Id, and never Content-Length.

Download control: receivers can override Content-Disposition behavior using query parameters. These work per-receiver, so with n=2 one receiver can download while the other displays inline.

Multi-receiver: when n > 1, all receivers get identical copies via lockstep fan-out. Each chunk is written to every receiver before the next chunk is read from the sender. Memory is bounded to roughly one chunk per receiver, with no per-receiver queuing. The slowest receiver paces the entire transfer — its backpressure applies to the sender and, transitively, to all other receivers.

Connection ordering: the receiver can connect before the sender. The server holds the connection until the sender arrives (up to 5-min TTL).

Reserved paths (/help, /noscript, etc.) return their own content on GET instead of acting as pipe receivers.

NameInTypeRequiredDescription
pathpathstringYesPipe path name to receive from. Must match the path used by the sender.
nqueryintegerNoExpected number of receivers. Must match the sender’s n value exactly — a mismatch returns 400. Default: 1.
downloadquerystringNoForce attachment disposition (triggers browser download) or suppress Content-Disposition entirely. Allowed values: true, false, yes, no, 1, 0.
filenamequerystringNoCustom download filename. Implies ?download — the response will set Content-Disposition: attachment; filename="<value>". Overrides any filename from the sender.
videoquerystringNoReturn an HTML page with an embedded MSE video player instead of raw pipe data. Only serves the HTML player when the client sends Accept: text/html. Allowed values: true, false, yes, no, 1, 0.
progressquerystringNoReturn real-time transfer progress as a Server-Sent Events stream (when Accept: text/event-stream) or an HTML dashboard (when Accept: text/html). Does NOT consume a pipe receiver slot. Allowed values: true, false, yes, no, 1, 0.
receivequerystringNoReturn the receive page instead of raw pipe data. The page itself takes no receiver slot. Browser detection: like video, only a client whose Accept header includes text/html gets the page. Other clients with ?receive fall through to the normal receiver and get the data. What the page does: it shows the pipe name and whether a sender is waiting (from the ?progress stream). On Receive, or by itself with autostart=1, the browser’s download manager receives /{path}?download (plus n and filename), so the data streams to disk at any size without passing through the page. Progress and the result are in the browser’s downloads list: the page cannot see the download and never reports it as finished. It fails with the server’s error text, for example Timed out waiting for sender. when no sender comes within 5 minutes (or the wait given), or a receiver-count mismatch; Receive again retries. What is happening on the path for all its senders and receivers (no sender yet, a sender waiting for receivers, a transfer running) is shown as information only, under a “Status for this name” label; while it cannot be read the page says Status unavailable, retrying…. Cancel frees the receiver slot while the receive waits; a download that has already started continues in the browser’s downloads. Without JavaScript it shows a plain download link. Page parameters: n (receiver count, editable on the page), filename (download name), autostart, and the receiver’s own wait and sha256, forwarded to its download (and to the no-JavaScript link). An invalid wait is dropped there. The page shows no checksum: compare one with ?status&transfer=<id> from a client that reads the X-Hoody-Pipe-Transfer-Id header. Values: ?receive (bare), ?receive=true, ?receive=yes, ?receive=1 → page. ?receive=false, ?receive=no, ?receive=0 → normal pipe receiver. Precedence: with several page parameters a browser gets progress, then video, then share, then receive. Security: CSP with nonces, connect-src 'self', frame-src 'self', no external resources, Cache-Control: no-store. Values from the URL are only rendered as escaped text. The page uses no cookies or browser storage.
sharequerystringNoReturn the share page: stream the screen (a screen, window or tab, optionally with its audio), a camera with microphone, or the microphone alone, live to the viewers of /{path}?video. The page itself takes no receiver slot. Browser detection: like video, only a client whose Accept header includes text/html gets the page. Other clients with ?share fall through to the normal receiver. What the page does: Start asks the browser for the capture (the user picks the screen, window or tab in the browser’s own picker), records it as WebM and sends it to the path as one streaming upload. The page shows a link for viewers (?video, with n when it is above 1; copy button and QR code), viewers connected, streaming state and elapsed time. Stop, or ending the share from the browser’s own controls, finishes the stream; Start again on the same path reconnects the viewers. A share nobody joins ends after the server’s 5-minute wait. A browser that cannot stream an upload gets a clear “not supported” message. Live: with the Live box ticked (live=1 on the page URL) the upload is a live broadcast (POST /{path}?live): it starts at once, viewers join and leave at any time through ?video&live=1 (each from the next keyframe; the page asks the recorder for one every 2 s), n is ignored, and the page shows the viewers watching now from ?progress. Live needs a WebM recording; a browser that cannot record WebM gets a “not supported” message. Page parameters: source, audio, surface, quality, fps, live, n (viewers). They only pre-fill the controls: capture always starts with the user’s click on Start. Values: ?share (bare), ?share=true, ?share=yes, ?share=1 → page. ?share=false, ?share=no, ?share=0 → normal pipe receiver. Security: CSP with nonces, connect-src 'self', no external resources, Cache-Control: no-store. The page uses no cookies or browser storage.
autostartquerystringNoReceive page (receive) only: 1, true, yes or bare starts the receive without a click once the page has loaded. Any other value, or none, waits for the user’s click. Ignored on every other request.
sourcequerystringNoShare page (share) only — pre-selects the source. screen (default) is a screen, window or tab; camera is a camera with microphone; audio is the microphone alone. Invalid → screen.
audioquerystringNoShare page (share) only — 1, true, yes or bare pre-checks “include audio” for a screen share (tab or system audio, where the browser offers it). Default off.
surfacequerystringNoShare page (share) only — which kind of surface the browser’s picker offers first. monitor (a whole screen), window or browser (a tab). The user still makes the choice. Default no hint.
qualityquerystringNoShare page (share) only — capture size and bit rate. low (about 480p), medium (about 720p, default) or high (about 1080p). Invalid → medium.
fpsqueryintegerNoShare page (share) only — frames per second for screen and camera, 1-60. Invalid → 30.
statusquerystringNoReturn one JSON snapshot of the name (PipeStatus) instead of receiving. It takes no receiver slot, is answered at once and is never held open; poll it from scripts and agents that cannot hold a ?progress stream. Checked before every other parameter (progress, video, share, receive, n, wait, and the modes live, ws and live with ws), for browsers too. HEAD with ?status returns the headers only. A POST/PUT with ?status is an ordinary sender. - state: idle (nobody on the name; an unused name is idle, not 404), waiting, streaming, complete, failed - kind: null when idle, "pipe" for an HTTP transfer, "ws" for a WebSocket pair (?ws), "live" for a live stream - peers: with "ws", the peers connected now (0-2); null for a transfer - transferId: set from when the transfer starts streaming, or from the sender’s arrival when it chose its own (transfer on the sender); the same value receivers get in X-Hoody-Pipe-Transfer-Id - hasSender, activeReceivers, totalReceivers, bytesTransferred, totalBytes, speed (bytes/s), eta (s), elapsed (s): the ?progress fields; speed is the average since streaming started, and in complete/failed elapsed and speed are frozen at the end - reason: why it failed (Timed out, Idle timeout, Sender disconnected, …), else null - sha256: the digest in complete when ?sha256 was on, else null A finished transfer stays visible for 30 s (at most the 1000 most recent ones), then the name reads idle; a new sender or receiver on the name starts a fresh waiting. Responses carry Cache-Control: no-store. Values: ?status (bare), true, yes, 1 → snapshot; false, no, 0 → normal receiver.
transferquerystringNoWith status: answer for one transfer, by the id a receiver got in X-Hoody-Pipe-Transfer-Id or the id a sender chose with its own transfer, even after the name was reused or its 30 s linger ended. A ?sha256 transfer, and every transfer with a sender-chosen id, leaves a receipt (state, reason, digest, bytes) kept 10 minutes (at most 1000; the oldest go first), so a receiver verifies its bytes after it has read them all: complete with the same sha256 means the bytes match. A sender-chosen id answers from the sender’s arrival on (waiting). An id that is neither the name’s current transfer nor a kept receipt for this name is 404. Not with ws (400).
waitqueryintegerNoHow long this receiver waits for the sender (and the other receivers of an n transfer), in seconds, counted from its own arrival: an integer from 1 to 3600 (default 300). When it passes it gets 408 Timed out waiting for sender. (no sender connected) or Timed out waiting for receivers. (the sender is there, fewer than n receivers are), and a waiting sender sees [INFO] A receiver disconnected. and keeps waiting. Anything else (abc, 1.5, 0, 3601, or a bare ?wait) is a 400 and nothing is registered.
wsquerystringNoWebSocket relay: open a duplex message connection on the name instead of receiving. Send GET /{path}?ws with a WebSocket upgrade (Upgrade: websocket; a plain GET is 426). Two peers on the same name are paired 1:1: every message one sends reaches the other with its type (text or binary) and boundaries kept. The first peer is accepted at once (101) and its messages are held until the second arrives (at most 4096 messages / 2 MiB); it waits wait seconds (default 300), then is closed with 4408 Timed out waiting for peer.. A third peer is 409. Behind HTTP/2 a browser opens the socket over HTTP/1.1 (no extended CONNECT is needed). A name holds one kind of session: a name with an HTTP transfer (sender or receivers) refuses ?ws with 409, and a name with a WebSocket pair refuses senders and receivers with 409. ?status reports a pair as kind: "ws" with peers 0-2; ?progress sees its state and byte counts. Subprotocol: the first peer’s Sec-WebSocket-Protocol choice (its first offered token, echoed back) is latched for the pair; the second peer must offer that token, or offer none when the first offered none (else 409). Limits: messages up to 1 MiB (a bigger one drops its sender’s connection: the sender sees 1006, with no close frame, and the other peer is closed with 1009 Message too big.). Each direction buffers at most 2 MiB for a slow reader; past that the pair is closed with 1013. Idle peers are pinged; one that does not answer within about 120 s is dropped. Close codes: a peer’s close is forwarded to the other: 1000-1003, 1007-1014 and 3000-4999 as sent; 1005 (no code) as 1000; anything else (1006, a dropped connection) as 1001 Peer disconnected.. The relay’s own: 1013 Relay buffer full: no peer yet. / Relay buffer full: the reader is too slow. / Relay dropped a message., 4408 Timed out waiting for peer., 1011 Internal error., 1001 Server shutting down.. Not with n (400). live with ws is 400 before any other check except status, with or without an upgrade. wait applies as for receivers. Values: ?ws (bare), true, yes, 1 → WebSocket relay; false, no, 0 → normal receiver. Upgrade response: an accepted peer gets 101 Switching Protocols; the first peer gets it at once, before the second arrives.
livequerystringNoWatch a live stream (a sender with ?live): join at any time, leave and rejoin at will. With no live sender yet, the viewer waits like a receiver (wait, default 300 s, then 408 Timed out waiting for sender.). The body is a suffix of the stream: no Content-Length, X-Hoody-Pipe-Live: 1, Cache-Control: no-store, no X-Hoody-Pipe-Transfer-Id. A WebM stream starts with its header, then a Cluster that begins with a video keyframe (any Cluster for audio-only); other bodies start at the next chunk. Slow viewers: a viewer that falls 2 MiB behind skips whole Clusters (WebM) until it has caught up to 512 KiB, resuming at a keyframe Cluster. One more than 8 MiB or 4096 pieces behind, one that takes nothing for 60 s, or the most-behind one when the server’s live memory budget is full, is cut: its body ends without the chunked terminator. The others are never slowed. Ending: after the sender’s clean end a viewer takes what it was already sent (up to 60 s), then its body ends normally; if the sender fails, every viewer is cut. A viewer’s response counts against the limits until it disconnects, or until 35 s after its body ended. Plain GET: a GET without live on a name whose live stream is running joins it the same way (a suffix, marked X-Hoody-Pipe-Live: 1). While ?live viewers wait for a sender, a plain GET or live=0 is 409. ?video&live serves the player for a live stream; on ?share (a browser), live=1 pre-ticks the page’s Live box. Not with n above 1, sha256 or ws (400). At most 256 viewers per stream and 4096 live viewer responses in all (429). Values: ?live (bare), true, yes, 1 → watch live; false, no, 0 → an ordinary receiver (409 on a live name).
sha256querystringNoAsk the server to hash the transfer (a receiver can switch it on alone). Read X-Hoody-Pipe-Transfer-Id, hash the bytes as you receive them, then compare with ?status&transfer=<id> (see transfer). The digest cannot come in-band: response headers go out before the body, and HTTP trailers are not sent. Values: ?sha256 (bare), true, yes, 1 → on; false, no, 0 → off.

This endpoint accepts no request body.

Streams the sender’s data. Content type matches what the sender sent (dangerous types are rewritten to text/plain; foreign parameters are dropped except for a safe charset).

Response headers:

  • X-Robots-Tag: none
  • X-Content-Type-Options: nosniff
  • X-Hoody-Pipe-Transfer-Id — this transfer’s id, for ?status&transfer=<id>, either a generated UUID or the id the sender chose with transfer (on receiver data responses only; not on live viewers)
  • Content-Type — forwarded from sender (or text/plain for dangerous types)
  • Content-Length — forwarded when the sender provided a valid ^\d{1,19}$ value AND the body is non-multipart
  • Content-Disposition — forwarded from sender (subject to the inline/attachment rules described above)
  • Access-Control-Expose-Headers: X-Hoody-Pipe-Transfer-Id (live viewers: X-Hoody-Pipe-Live), plus X-Piping, X-Hoody-Pipe when the sender provided either custom header
HTTP/1.1 200 OK
X-Robots-Tag: none
X-Content-Type-Options: nosniff
X-Hoody-Pipe-Transfer-Id: 4f1c2a9e-8b3d-4e6f-9a1b-2c3d4e5f6a7b
Content-Type: text/plain
hello from sender
Terminal window
# Simple receive (default n=1)
curl https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath
# Force download with custom filename
curl "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/report?download&filename=report.txt" -o report.txt
# Receive into a video player (browser only)
curl -H "Accept: text/html" "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/stream?video" -L
# Watch progress as SSE
curl -N -H "Accept: text/event-stream" "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath?progress"

Send data to the specified pipe path. The sender’s request body is streamed directly to receiver(s) when they connect, with no server-side storage.

Lifecycle:

  1. Sender POSTs to a path and gets back a streaming response with [INFO] status messages.
  2. Server waits for n receivers to connect (default: 1).
  3. Once all receivers connect, data streams from sender to all receivers simultaneously.
  4. Sender receives [INFO] Upload complete. then [INFO] Transfer complete.

Status messages (streamed to the sender as text/plain):

[INFO] Waiting for 1 receiver(s) to connect...
[INFO] Streaming to 1 receiver(s)...
[INFO] Upload complete.
[INFO] Transfer complete.

Multipart uploads: when Content-Type matches multipart/form-data (case-insensitive), the server extracts the first file part (non-file form fields are drained and skipped) and streams its contents. The part’s Content-Type and Content-Disposition are forwarded to receivers. The transfer ends when that part ends: anything after it in the body is ignored, and the server stops reading the body once the transfer finishes. A body cut off or dropped before the chosen part ends fails the transfer: the sender gets [ERROR] Transfer failed, receivers still waiting get 500, and a receiver already streaming sees its body end with an error. Content-Length is NOT forwarded for multipart inputs (the response uses chunked transfer encoding); the part’s declared byte count is unreliable as a receiver-side length, and forwarding it reintroduces response-smuggling/desync risks. If the body has no file part, the first form field named input_text (the noscript page’s text mode, capped at 1 MiB) is sent instead, as text/plain; charset=utf-8; a file part anywhere in the body always wins over it. A multipart body with neither, or whose input_text exceeds 1 MiB with no file part, is rejected with a [ERROR] Transfer failed status and receivers receive 500. Slow-loris bound: parsing aborts if no part arrives within 30 s, and the upload times out if the body stops making progress for 5 minutes.

Custom headers: set X-Hoody-Pipe or X-Piping request headers to forward arbitrary metadata to receivers. Each header is capped at 8 KiB (over-cap headers are dropped) and CRLF/control chars are stripped. The receiver response carries Access-Control-Expose-Headers: X-Piping, X-Hoody-Pipe only when at least one was supplied.

Content-Type safety: dangerous MIME types that execute scripts in browsers (HTML, SVG, JavaScript, the full WHATWG JS-essence list, XHTML, XML) are rewritten to text/plain. Parameters are stripped except for a single safe charset (utf-8, us-ascii, iso-8859-1). Embedded CR/LF and control chars are dropped.

Connection ordering: either sender or receiver(s) can connect first; the server holds the early party until counterparts arrive.

Limits:

  • Path length: max 1024 characters
  • Receiver count (n): 1–256
  • Pending transfers: max 1000 server-wide
  • Active transfers: max 1000 server-wide
  • Waiting for receivers: wait seconds (default 300, at most 3600)

Also accessible without prefix (e.g. POST /myfile).

NameInTypeRequiredDescription
pathpathstringYesUnique pipe path name. Must not be a reserved path (/, /help, /noscript, /favicon.ico, /robots.txt). POST/PUT to a reserved path return 400; any other method except GET/HEAD/OPTIONS returns 405 with [ERROR] Method <verb> is not allowed. and Allow: GET, HEAD, OPTIONS. Examples: myfile, transfer123, secret.png, logs/today.
nqueryintegerNoNumber of receivers to wait for before starting the transfer. All receivers get identical copies of the data (fan-out). Must be a positive integer, max 256. Default: 1.
waitqueryintegerNoHow long this sender waits for its n receivers, in seconds, counted from its own arrival: an integer from 1 to 3600 (default 300). When it passes, the status stream ends with [ERROR] Timed out waiting for receivers. Receivers already waiting keep their own wait, so another sender may still come for them. Anything else (abc, 1.5, 0, 3601, or a bare ?wait) is a 400 and nothing is registered, so a script is never given a shorter wait than it asked for. Through the Hoody edge: a sender whose large body is still waiting is cut after about 30 minutes (the proxy’s request-body timeout); receivers and small senders can wait the full hour. For long waits with large files, let the receiver wait (?wait=3600) and start the sender once ?status shows it.
sha256querystringNoCompute the SHA-256 of the bytes forwarded to receivers (for multipart, of the file part or input_text, which is what receivers get). On a complete transfer the status stream gets [INFO] SHA-256: <64 hex> between [INFO] Upload complete. and [INFO] Transfer complete., and the digest is in the ?progress done event and in ?status. A failed transfer has no digest. Any receiver can switch hashing on as well. Hashing costs about a third more CPU per byte (single stream ≈0.75 → ≈0.55 GB/s on the reference host), so it is opt-in. Values: ?sha256 (bare), true, yes, 1 → on; false, no, 0 → off.
transferquerystringNoThis transfer’s own id instead of a generated one: 16-64 characters A-Z a-z 0-9 _ -. A sender cannot read response headers while it uploads, so it picks the id up front to follow its own transfer with ?status&transfer=<id>. The id is the transferId in ?status from the sender’s arrival (also while it waits), the X-Hoody-Pipe-Transfer-Id receivers get, and the done event’s transferId. A transfer with its own id leaves a receipt for every outcome, kept 10 minutes: complete, or failed with its reason, also when it ended before streaming (Timed out waiting for receivers, Sender disconnected, Server at capacity). Any other value is 400. An id still held by a waiting, running or finished transfer on any name, or by a kept receipt, is 409. Not with ws (400). On a live sender it is the live stream’s transferId in ?status from the start, and its receipt ("kind": "live", complete or failed) once it ends; live viewers get no X-Hoody-Pipe-Transfer-Id.
livequerystringNoLive broadcast: start streaming at once, even with no viewers, and let any number of viewers join and leave at any time (GET /{path}?live; up to 256 at once). Nothing waits for a receiver and nothing is stored: bytes sent while nobody watches are dropped. Viewers are never in lockstep: one that falls behind skips ahead (WebM: whole Clusters, resuming at a keyframe Cluster) or is cut, and never slows the sender or the others. WebM bodies (starting with the EBML magic, e.g. MediaRecorder or ffmpeg -f webm) get keyframe joins: a viewer receives the stream header, then the stream from the next Cluster that starts with a video keyframe (any Cluster for audio-only). Join delay is the keyframe interval. Any other body (logs, text, other containers) is sent to a viewer from the next chunk on. Status lines (at most four): [INFO] Live: streaming. Viewers can join at any time., then [INFO] Live stream ended (peak N viewers). on a clean end, or the idle-timeout / failure [ERROR] lines. Viewer counts are on ?progress and ?status (kind: "live"). Ending: a clean end lets each viewer take what it was already sent (up to 60 s), then its body ends normally. A sender that disconnects, fails or idles 5 minutes cuts every viewer (their bodies end without the chunked terminator, which clients report as an error). The name is free for reuse as soon as the sender ends. Not with n above 1, sha256, ws or a multipart body (400). A name with waiting or streaming ordinary transfers, or a WebSocket pair, is 409. At most 100 live streams and 4096 viewer responses at once (429). wait is validated but a live sender never waits. Values: ?live (bare), true, yes, 1 → live; false, no, 0 → an ordinary sender.

Data to stream to receiver(s). Any content type is accepted, and the body is optional (an empty POST is valid: receivers get an empty response).

Accepted media types (the caller picks one with its Content-Type header):

  • application/octet-stream (binary)

  • text/plain

  • multipart/form-data

  • */* (binary)

  • Binary files: use application/octet-stream or the file’s actual MIME type.

  • Text: use text/plain.

  • Multipart: use multipart/form-data for browser uploads. Only the first file part is streamed; leading non-file form fields are drained and skipped.

  • No body: an empty POST is valid.

The body schema has no fields. Body content is forwarded byte-for-byte to receivers (for multipart, the chosen file part or input_text is what receivers get).

Sender connected. The response body streams [INFO]/[ERROR] status messages in real time as text/plain.

Success sequence:

[INFO] Waiting for 1 receiver(s) to connect...
[INFO] Streaming to 1 receiver(s)...
[INFO] Upload complete.
[INFO] Transfer complete.

Receiver disconnected:

[INFO] A receiver disconnected.
[INFO] All receivers disconnected before transfer completed.

With ?sha256:

[INFO] Upload complete.
[INFO] SHA-256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
[INFO] Transfer complete.

Error:

[ERROR] Timed out waiting for receivers.
[ERROR] Transfer failed — sender encountered an error.
Terminal window
# Send a file
curl -X POST -T secret.png "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/secret.png"
# Send text
curl -X POST --data-binary "hello from sender" "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath"
# Send a multipart form upload (first file part is streamed)
curl -X POST -F "file=@report.pdf" "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/report"
# Fan out to 3 receivers with custom metadata
curl -X POST -T backup.tar.gz \
-H "X-Hoody-Pipe: encrypted-v1" \
-H "X-Piping: backup-job-42" \
"https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/backup?n=3"

Identical to POST — send data to the specified pipe path. PUT is provided as an alias because curl -T file URL uses PUT, making it natural for file transfers.

Terminal window
# Send a file (uses PUT)
curl -T myfile https://pipe.example.com/api/v1/pipe/mypath
# Send stdin
echo 'hello' | curl -T - https://pipe.example.com/api/v1/pipe/mypath
# Send a directory as tar.gz
tar czf - ./mydir | curl -T - https://pipe.example.com/api/v1/pipe/mydir.tar.gz

Parameter semantics, request body handling, status messages, and error codes are described below.

NameInTypeRequiredDescription
pathpathstringYesUnique pipe path name (same rules as POST — no reserved paths, max 1024 chars).
nqueryintegerNoNumber of receivers to wait for (must match receivers’ n, max 256). Default: 1.
waitqueryintegerNoSeconds this sender waits for its receivers, 1–3600 (default 300). Same rules as POST.
sha256querystringNoCompute the SHA-256 of the forwarded bytes. Same rules as POST.
transferquerystringNoThis transfer’s own id (16-64 characters A-Z a-z 0-9 _ -). Same rules as POST.
livequerystringNoLive broadcast (any number of viewers join and leave at any time). Same rules as POST.

Data to stream — any content type. multipart/form-data is supported (the first file part is extracted; non-file fields are skipped).

The body schema has no fields. Body content is forwarded byte-for-byte to receivers.

Sender connected — streaming status messages (identical to POST).

[INFO] Waiting for 1 receiver(s) to connect...
[INFO] Streaming to 1 receiver(s)...
[INFO] Upload complete.
[INFO] Transfer complete.
Terminal window
# Send a file (PUT is what curl -T uses)
curl -T secret.png "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/secret.png"
# Send stdin
echo 'hello' | curl -T - "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath"
# Send a tar.gz of a directory
tar czf - ./mydir | curl -T - "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mydir.tar.gz"
# Fan out to 2 receivers
curl -T backup.tar.gz "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/backup?n=2"

Handles CORS preflight requests for cross-origin browser access. Returns permissive CORS headers reflecting the request Origin.

Headers returned:

  • Access-Control-Allow-Origin — reflects Origin (or * if none/null)
  • Access-Control-Allow-Methods — GET, POST, PUT, OPTIONS (HEAD is supported same-origin on reserved paths only)
  • Access-Control-Allow-Headers — Content-Type, Content-Disposition, Authorization, X-Piping, X-Hoody-Pipe
  • Access-Control-Allow-Credentials — true (when Origin is present and not "null")
  • Access-Control-Max-Age — 86400 (24 hours)
  • Access-Control-Allow-Private-Network — true (when requested)

The "null" origin string is rejected — defaults to *, which blocks credentialed requests from sandboxed iframes.

NameInTypeRequiredDescription
pathpathstringYesAny path — OPTIONS is handled identically for all paths.

This endpoint accepts no request body.

CORS preflight response with permissive headers.

Terminal window
curl -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, X-Hoody-Pipe" \
"https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath"

Pipe status headers. HEAD /{path}?status returns the headers of the ?status snapshot: a cheap liveness probe. HEAD without ?status on a pipe path is 405.

NameInTypeRequiredDescription
pathpathstringYesPipe path name.
statusquerystringYesMust be on (?status, true, yes, 1).

This endpoint accepts no request body.

Returns the headers of the ?status snapshot (Content-Type: application/json, Cache-Control: no-store).

Terminal window
curl -I "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath?status"