Hoody Pipe
Section titled “Hoody Pipe”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.
Web Interface
Section titled “Web Interface”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.
GET /api/v1/pipe/
Section titled “GET /api/v1/pipe/”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).
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
name | query | string | No | Pre-fills the pipe name (up to 1024 characters). Absent → a random name. |
n | query | integer | No | Pre-fills the receiver count (1-256). Invalid → 1. Default: 1. |
text | query | string | No | Pre-fills the text to send (up to 100000 characters) and selects text mode. |
mode | query | string | No | Selects file or text mode. Default file, or text when text is given. Allowed values: file, text. |
filename | query | string | No | Pre-fills the file name used for a text or pasted send (up to 255 characters). |
autostart | query | string | No | Text 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.
Response
Section titled “Response”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>SDK usage
Section titled “SDK usage”# Open the send page with a pre-filled name and receiver countcurl "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 loadcurl "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/?mode=text&text=hello&filename=note.txt&autostart=true"GET /api/v1/pipe/noscript
Section titled “GET /api/v1/pipe/noscript”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.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
path | query | string | No | Pre-fill the pipe path. Only URL-safe characters allowed. |
mode | query | string | No | Input mode: file for file picker, text for textarea. Default: "file". Allowed values: file, text. |
wait | query | integer | No | Seconds the upload waits for its receivers (1-3600), forwarded to the form’s POST. An invalid value is dropped. |
sha256 | query | string | No | 1, 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.
Response
Section titled “Response”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>SDK usage
Section titled “SDK usage”curl "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/noscript?path=mypath&mode=file"Service Info
Section titled “Service Info”Plain-text usage instructions, the standardized health endpoint, and Prometheus metrics.
GET /api/v1/pipe/help
Section titled “GET /api/v1/pipe/help”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.
Response
Section titled “Response”Returns usage instructions as text/plain.
Hoody Pipe 1.6.1Streaming 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.gzSDK usage
Section titled “SDK usage”curl https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/helpimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.pipe.kit.getHelp();GET /api/v1/pipe/health
Section titled “GET /api/v1/pipe/health”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.
Response
Section titled “Response”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"}| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | Health state. Always ok when reachable. |
service | string | Yes | Service identifier. |
built | string | No | Module mtime as ISO 8601 string. |
started | string | Yes | Process start time as ISO 8601 string. |
memory | object | No | Resident set size and V8 heap usage in bytes. |
fds | integer | No | Open file descriptor count. |
pid | integer | Yes | Process ID. |
ip | string | Yes | Container IP address. |
userAgent | string | No | User-Agent of the requesting client. |
SDK usage
Section titled “SDK usage”curl https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/healthimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.pipe.kit.getHealth();GET /api/v1/pipe/metrics
Section titled “GET /api/v1/pipe/metrics”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) andhoody_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?progressstreamshoody_pipe_transfers_total{result="completed|failed"}(counter): transfers that started streaming, one per transfer whatevernishoody_pipe_transferred_bytes_total(counter): bytes read from senders, counted once (not per receiver), including the part of a transfer that later failedhoody_pipe_hashed_bytes_total(counter): bytes hashed for?sha256hoody_pipe_rejections_total{reason}(counter):pending_capandspectator_cap(one per refused request),active_cap(one per refused pipe),wait_timeout(one per sender or receiver whosewaitpassed),idle_timeout(one per transfer ended by the 5-minute idle limit),ws_cap(one per refused?wsrequest),live_cap(one per refused live request)hoody_pipe_ws_pairs_active{state="waiting|paired"}(gauge) andhoody_pipe_ws_relayed_bytes_total(counter):?wspairs and the message payload bytes they relayed, both directionshoody_pipe_live_viewer_joins_total(counter) andhoody_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.
Response
Section titled “Response”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 counterhoody_pipe_transfers_total{result="completed"} 12hoody_pipe_transfers_total{result="failed"} 1Method not allowed. Metrics accepts GET, HEAD and OPTIONS only, and the response carries Allow: GET, HEAD, OPTIONS.
[ERROR] Method POST is not allowed.| Error Code | Title | Description | Resolution |
|---|---|---|---|
METHOD_NOT_ALLOWED | HTTP method not supported | Metrics accepts GET, HEAD and OPTIONS only | Use GET |
SDK usage
Section titled “SDK usage”curl https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/metricsimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.pipe.kit.getMetrics();Data Transfer
Section titled “Data Transfer”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.
GET /api/v1/pipe/{path}
Section titled “GET /api/v1/pipe/{path}”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 totext/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-suppliedinlineis upgraded toattachmentunless the effectiveContent-Typeis on the inline-safe allowlist (image, audio, video, text/plain, application/pdf, excludingimage/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.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
path | path | string | Yes | Pipe path name to receive from. Must match the path used by the sender. |
n | query | integer | No | Expected number of receivers. Must match the sender’s n value exactly — a mismatch returns 400. Default: 1. |
download | query | string | No | Force attachment disposition (triggers browser download) or suppress Content-Disposition entirely. Allowed values: true, false, yes, no, 1, 0. |
filename | query | string | No | Custom download filename. Implies ?download — the response will set Content-Disposition: attachment; filename="<value>". Overrides any filename from the sender. |
video | query | string | No | Return 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. |
progress | query | string | No | Return 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. |
receive | query | string | No | Return 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. |
share | query | string | No | Return 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. |
autostart | query | string | No | Receive 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. |
source | query | string | No | Share 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. |
audio | query | string | No | Share 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. |
surface | query | string | No | Share 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. |
quality | query | string | No | Share page (share) only — capture size and bit rate. low (about 480p), medium (about 720p, default) or high (about 1080p). Invalid → medium. |
fps | query | integer | No | Share page (share) only — frames per second for screen and camera, 1-60. Invalid → 30. |
status | query | string | No | Return 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. |
transfer | query | string | No | With 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). |
wait | query | integer | No | How 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. |
ws | query | string | No | WebSocket 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. |
live | query | string | No | Watch 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). |
sha256 | query | string | No | Ask 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.
Response
Section titled “Response”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: noneX-Content-Type-Options: nosniffX-Hoody-Pipe-Transfer-Id— this transfer’s id, for?status&transfer=<id>, either a generated UUID or the id the sender chose withtransfer(on receiver data responses only; not on live viewers)Content-Type— forwarded from sender (ortext/plainfor dangerous types)Content-Length— forwarded when the sender provided a valid^\d{1,19}$value AND the body is non-multipartContent-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), plusX-Piping, X-Hoody-Pipewhen the sender provided either custom header
HTTP/1.1 200 OKX-Robots-Tag: noneX-Content-Type-Options: nosniffX-Hoody-Pipe-Transfer-Id: 4f1c2a9e-8b3d-4e6f-9a1b-2c3d4e5f6a7bContent-Type: text/plain
hello from senderBad request — invalid parameters, active transfer, slots full, or n mismatch.
[ERROR] Invalid receiver count — "n" must be a positive integer.| Error Code | Title | Description | Resolution |
|---|---|---|---|
SERVICE_WORKER | Service Worker request blocked | Requests with Service-Worker: script header are rejected to prevent service worker registration via pipe paths | Do not register service workers via pipe paths |
ACTIVE_TRANSFER | Path has an active transfer | A transfer is already streaming on this path — no new receivers can join | Wait for the transfer to complete, or use a different path |
RECEIVER_SLOTS_FULL | All receiver slots taken | All n receiver slots for this path are occupied | Wait for a receiver to disconnect, or use a different path |
INVALID_N | Invalid receiver count | n is not a valid positive integer (1–256) | Set n between 1 and 256 |
N_MISMATCH | Receiver count mismatch | This receiver’s n doesn’t match existing sender/receivers on this path | Use the same n value as the sender |
INVALID_WAIT | Invalid wait | wait is not a decimal integer (or is empty) | Set wait to whole seconds between 1 and 3600 |
WAIT_OUT_OF_RANGE | Wait out of range | wait is below 1 or above 3600 seconds | Set wait between 1 and 3600 |
INVALID_TRANSFER_ID | Invalid transfer id | With status, transfer is not a transfer id (16-64 characters A-Z a-z 0-9 _ -) | Pass the value of the X-Hoody-Pipe-Transfer-Id response header |
LIVE_WITH_N | n with live | n other than 1 on a live join (live, or a plain GET on a running live stream) | Drop n |
LIVE_WITH_SHA256 | sha256 with live | A live viewer gets a suffix; there is no digest to verify | Drop sha256 |
WS_TRANSFER_UNSUPPORTED | transfer with ws | A WebSocket pair has no transfer id | Drop transfer |
WS_N_UNSUPPORTED | n with ws | A WebSocket pair has exactly two peers; n is refused | Drop n; use one name per pair |
WS_LIVE_UNSUPPORTED | live with ws | live and ws cannot be combined | Use one of them |
WS_INVALID_PROTOCOL | Invalid subprotocol offer | Sec-WebSocket-Protocol is not a comma-separated list of tokens | Offer RFC 7230 tokens only |
WS_INVALID_HANDSHAKE | Invalid WebSocket handshake | The upgrade request was refused by the WebSocket layer (for example a missing or bad Sec-WebSocket-Key) | Use a WebSocket client library |
Method not allowed.
[ERROR] Method DELETE is not allowed. Use GET, POST, or PUT.| Error Code | Title | Description | Resolution |
|---|---|---|---|
METHOD_NOT_ALLOWED | HTTP method not supported | Pipe paths accept GET, POST, PUT, OPTIONS, and HEAD with ?status. Allow lists HEAD only when status is on. | Use GET to receive data |
Timeout — the unestablished pipe expired after the 5-minute TTL.
[ERROR] Timed out waiting for sender.| Error Code | Title | Description | Resolution |
|---|---|---|---|
TTL_EXPIRED | Pipe TTL expired | Waited 5 minutes but counterpart did not connect; the pipe was evicted | Retry — ensure sender and receiver connect within 5 minutes |
TTL_EXPIRED_RECEIVERS | Pipe TTL expired waiting for receivers | Waited wait seconds (default 300); the sender is connected, but fewer than n receivers are. The sender keeps its own wait. | Retry — ensure all n receivers connect within 5 minutes |
Path too long.
[ERROR] Path too long (max 1024 characters).| Error Code | Title | Description | Resolution |
|---|---|---|---|
PATH_TOO_LONG | Path exceeds length limit | Path exceeds 1024 characters | Use a shorter path |
Too many transfers.
[ERROR] Too many pending transfers. Try again later.| Error Code | Title | Description | Resolution |
|---|---|---|---|
TOO_MANY_PENDING | Pending transfer limit reached | Server has 1000 pending pipes | Wait and retry |
TOO_MANY_ACTIVE | Active transfer limit reached | Server has 1000 active transfers — pipe established but cannot stream | Wait for transfers to finish, then retry |
TOO_MANY_WS_PAIRS | WebSocket pair limit reached | Server has 500 WebSocket pairs (waiting or paired); joining an existing pair is still allowed | Wait and retry |
LIVE_FULL | Live stream full | The live stream has 256 viewers (waiting, watching, or ended in the last 35 s) | Retry later |
TOO_MANY_LIVE_VIEWERS | Live viewer limit reached | Server has 4096 live viewer responses (waiting, watching, or ended in the last 35 s) | Wait and retry |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
SHUTTING_DOWN | Server shutting down | A ?live viewer waiting for a sender when the server stops | Retry after the restart |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
WS_NOT_ENABLED | WebSocket relay not enabled | No WebSocket handler on this server, or it is shutting down | Use a hoody-pipe started from its CLI, or retry after a restart |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
WS_UPGRADE_REQUIRED | WebSocket upgrade required | ?ws needs Connection: Upgrade and Upgrade: websocket | Open the URL with a WebSocket client (ws:// or wss://) |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
WS_PAIR_FULL | WebSocket pair full | Two peers are already on the name | Use another name, or wait for the pair to close |
WS_IN_USE_BY_TRANSFER | Name in use by an HTTP transfer | ws on a name with a waiting or streaming sender or receiver | Use another name, or wait until ?status reads idle |
IN_USE_BY_WS | Name in use by a WebSocket pair | A sender or receiver on a name with a WebSocket pair | Use another name, or wait until the pair closes |
WS_PROTOCOL_MISMATCH | Subprotocol does not match the peer’s | The first peer latched a subprotocol (or none); this peer did not offer it (or offered one) | Offer the same subprotocol as the first peer, or none when it offered none |
IN_USE_BY_LIVE | Name in use by a live stream | live=0, or a plain GET while ?live viewers wait for a sender | Add ?live |
IN_USE_BY_TRANSFER | Name in use by an ordinary transfer | live on a name with a waiting or streaming ordinary sender or receiver | Use another name, or wait until ?status reads idle |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
TRANSFER_NOT_FOUND | Unknown or expired transfer | Receipts are kept for ?sha256 transfers and transfers with a sender-chosen id only, 10 minutes, at most 1000 | Look the transfer up sooner, or treat the digest as unavailable (not as a mismatch) |
SDK usage
Section titled “SDK usage”# 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 filenamecurl "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 SSEcurl -N -H "Accept: text/event-stream" "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath?progress"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
// Simple receive (default n=1)await client.pipe.receive('mypath');
// Multi-receiver receiveawait client.pipe.receive('broadcast', { n: 3 });
// Force download with custom filenameawait client.pipe.receive('report', { download: 'true', filename: 'report.txt' });
// Browser video player (server returns HTML; no pipe slot consumed)await client.pipe.receive('stream', { video: 'true' });
// Spectator progress stream (no pipe slot consumed)await client.pipe.receive('mypath', { progress: 'true' });POST /api/v1/pipe/{path}
Section titled “POST /api/v1/pipe/{path}”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:
- Sender
POSTs to a path and gets back a streaming response with[INFO]status messages. - Server waits for
nreceivers to connect (default: 1). - Once all receivers connect, data streams from sender to all receivers simultaneously.
- 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:
waitseconds (default 300, at most 3600)
Also accessible without prefix (e.g. POST /myfile).
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
path | path | string | Yes | Unique 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. |
n | query | integer | No | Number 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. |
wait | query | integer | No | How 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. |
sha256 | query | string | No | Compute 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. |
transfer | query | string | No | This 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. |
live | query | string | No | Live 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. |
Request Body
Section titled “Request Body”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-streamor the file’s actual MIME type. -
Text: use
text/plain. -
Multipart: use
multipart/form-datafor browser uploads. Only the first file part is streamed; leading non-file form fields are drained and skipped. -
No body: an empty
POSTis 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).
Response
Section titled “Response”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.Bad request: invalid parameters, reserved path, duplicate sender, or receiver count mismatch.
[ERROR] '/help' is a reserved path. Use a custom path like '/myfile' or '/transfer123'.| Error Code | Title | Description | Resolution |
|---|---|---|---|
RESERVED_PATH | Path is reserved | The requested path is a system-reserved path (/, /help, /noscript, etc.) | Choose a different path that is not reserved |
DUPLICATE_SENDER | Path already has a sender | Another sender is already connected to this path waiting for receivers | Use a different path, or wait for the existing transfer to complete |
ACTIVE_TRANSFER | Path has an active transfer | The path is currently in use by a streaming transfer | Wait for the current transfer to finish, or use a different path |
INVALID_N | Invalid receiver count | The n query parameter is not a valid positive integer, or exceeds max 256 | Set n to a positive integer between 1 and 256 |
N_MISMATCH | Receiver count mismatch | Sender’s n doesn’t match existing receivers’ n on this path | Use the same n value as the receivers |
CONTENT_RANGE | Content-Range not supported | Content-Range headers are not supported for streaming transfers | Send the complete file without range headers |
INVALID_WAIT | Invalid wait | wait is not a decimal integer (or is empty) | Set wait to whole seconds between 1 and 3600 |
WAIT_OUT_OF_RANGE | Wait out of range | wait is below 1 or above 3600 seconds | Set wait between 1 and 3600 |
LIVE_WITH_N | n with live | A live stream has no fixed receiver count; n other than 1 is refused (on senders and on every live join) | Drop n |
LIVE_WITH_SHA256 | sha256 with live | Viewers get a suffix of the stream, so there is no single digest to verify | Drop sha256, or send an ordinary transfer |
INVALID_TRANSFER | Invalid transfer id | transfer is not 16-64 characters A-Z a-z 0-9 _ - | Pick an id of 16-64 letters, digits, _ or - |
LIVE_WITH_WS | live with ws | live and ws are different modes; refused before any other check (a GET with status gets the status instead) | Use one of them |
LIVE_MULTIPART | Multipart with live | A live body is streamed as sent; multipart form uploads are not supported | Send the raw bytes (e.g. curl -T -) |
Method not allowed: pipe paths accept GET, POST, PUT, OPTIONS (HEAD only with status, and on the reserved paths).
[ERROR] Method DELETE is not allowed. Use GET, POST, or PUT.| Error Code | Title | Description | Resolution |
|---|---|---|---|
METHOD_NOT_ALLOWED | HTTP method not supported | Pipe paths accept GET (receive), POST/PUT (send), and OPTIONS (CORS preflight). HEAD works with status and on the reserved paths. | Use GET to receive data, POST or PUT to send data |
The name holds another kind of session (a WebSocket pair, a live stream, or, for a live sender, an ordinary transfer), or the transfer id is in use.
[ERROR] '/mypath' is in use by a WebSocket pair.| Error Code | Title | Description | Resolution |
|---|---|---|---|
IN_USE_BY_WS | Name in use by a WebSocket pair | A sender on a name with a WebSocket pair | Use another name, or wait until the pair closes |
IN_USE_BY_LIVE | Name in use by a live stream | An ordinary sender (or live=0) on a name with an active live stream or ?live viewers waiting | Use another name, or send with ?live |
IN_USE_BY_TRANSFER | Name in use by an ordinary transfer | A live sender on a name with a waiting or streaming ordinary sender or receiver | Use another name, or wait until ?status reads idle |
TRANSFER_ID_IN_USE | Transfer id in use | transfer is held by a waiting, running or finished transfer (any name) or by a kept receipt | Pick a new id for each transfer |
Path too long: exceeds 1024 characters.
[ERROR] Path too long (max 1024 characters).| Error Code | Title | Description | Resolution |
|---|---|---|---|
PATH_TOO_LONG | Path exceeds length limit | The pipe path exceeds the maximum length of 1024 characters | Use a shorter path name |
Too many transfers: server at capacity.
[ERROR] Too many pending transfers. Try again later.| Error Code | Title | Description | Resolution |
|---|---|---|---|
TOO_MANY_PENDING | Pending transfer limit reached | Server has 1000 unestablished pipes. New transfers rejected until existing ones complete or expire (5-min TTL). | Wait for transfers to complete or expire, then retry |
TOO_MANY_ACTIVE | Active transfer limit reached | Server has 1000 concurrent active transfers | Wait for active transfers to finish, then retry |
TOO_MANY_LIVE | Live stream limit reached | Server has 100 live streams (or 1000 active transfers in all) | Wait and retry |
SDK usage
Section titled “SDK usage”# Send a filecurl -X POST -T secret.png "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/secret.png"
# Send textcurl -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 metadatacurl -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"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
// Send text to a single receiver (default n=1)await client.pipe.send('mypath', 'hello from sender');
// Fan out to 3 receivers (options go in the third argument)await client.pipe.send('backup.tar.gz', backupBytes, { n: 3 });
// Hash the transfer and give it a sender-chosen idawait client.pipe.send('report', reportBytes, { sha256: 'true', transfer: 'backup-2026-01-20-a1' });
// Start a live broadcastawait client.pipe.send('stream', mediaBytes, { live: 'true' });
// Wait up to 10 minutes for the receiversawait client.pipe.send('logs/today', logBytes, { wait: 600 });PUT /api/v1/pipe/{path}
Section titled “PUT /api/v1/pipe/{path}”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.
# Send a file (uses PUT)curl -T myfile https://pipe.example.com/api/v1/pipe/mypath
# Send stdinecho 'hello' | curl -T - https://pipe.example.com/api/v1/pipe/mypath
# Send a directory as tar.gztar czf - ./mydir | curl -T - https://pipe.example.com/api/v1/pipe/mydir.tar.gzParameter semantics, request body handling, status messages, and error codes are described below.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
path | path | string | Yes | Unique pipe path name (same rules as POST — no reserved paths, max 1024 chars). |
n | query | integer | No | Number of receivers to wait for (must match receivers’ n, max 256). Default: 1. |
wait | query | integer | No | Seconds this sender waits for its receivers, 1–3600 (default 300). Same rules as POST. |
sha256 | query | string | No | Compute the SHA-256 of the forwarded bytes. Same rules as POST. |
transfer | query | string | No | This transfer’s own id (16-64 characters A-Z a-z 0-9 _ -). Same rules as POST. |
live | query | string | No | Live broadcast (any number of viewers join and leave at any time). Same rules as POST. |
Request Body
Section titled “Request Body”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.
Response
Section titled “Response”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.Bad request.
[ERROR] '/help' is a reserved path. Use a custom path like '/myfile' or '/transfer123'.| Error Code | Title | Description | Resolution |
|---|---|---|---|
RESERVED_PATH | Path is reserved | The requested path is a system-reserved path | Choose a different path |
DUPLICATE_SENDER | Path already has a sender | Another sender is already connected | Use a different path or wait |
INVALID_N | Invalid receiver count | n is not a valid positive integer (1–256) | Set n to a positive integer between 1 and 256 |
CONTENT_RANGE | Content-Range not supported | Content-Range headers are not supported | Send the complete file |
INVALID_WAIT | Invalid wait | wait is not a decimal integer (or is empty) | Set wait to whole seconds between 1 and 3600 |
WAIT_OUT_OF_RANGE | Wait out of range | wait is below 1 or above 3600 seconds | Set wait between 1 and 3600 |
LIVE_WITH_N | n with live | A live stream has no fixed receiver count; n other than 1 is refused (on senders and on every live join) | Drop n |
LIVE_WITH_SHA256 | sha256 with live | Viewers get a suffix of the stream, so there is no single digest to verify | Drop sha256, or send an ordinary transfer |
INVALID_TRANSFER | Invalid transfer id | transfer is not 16-64 characters A-Z a-z 0-9 _ - | Pick an id of 16-64 letters, digits, _ or - |
LIVE_WITH_WS | live with ws | live and ws are different modes; refused before any other check (a GET with status gets the status instead) | Use one of them |
LIVE_MULTIPART | Multipart with live | Multipart form uploads are not supported with live | Send the raw bytes |
Method not allowed.
[ERROR] Method DELETE is not allowed. Use GET, POST, or PUT.| Error Code | Title | Description | Resolution |
|---|---|---|---|
METHOD_NOT_ALLOWED | HTTP method not supported | Pipe paths accept GET, POST, PUT, OPTIONS only | Use GET to receive, POST or PUT to send |
Path too long.
[ERROR] Path too long (max 1024 characters).| Error Code | Title | Description | Resolution |
|---|---|---|---|
PATH_TOO_LONG | Path exceeds length limit | Path exceeds 1024 characters | Use a shorter path |
Too many transfers.
[ERROR] Too many pending transfers. Try again later.| Error Code | Title | Description | Resolution |
|---|---|---|---|
TOO_MANY_PENDING | Pending transfer limit reached | Server has 1000 pending pipes | Wait and retry |
TOO_MANY_LIVE | Live stream limit reached | Server has 100 live streams (or 1000 active transfers in all) | Wait and retry |
| Error Code | Title | Description | Resolution |
|---|---|---|---|
IN_USE_BY_WS | Name in use by a WebSocket pair | A sender on a name with a WebSocket pair | Use another name, or wait until the pair closes |
IN_USE_BY_LIVE | Name in use by a live stream | An ordinary sender (or live=0) on a name with an active live stream or ?live viewers waiting | Use another name, or send with ?live |
IN_USE_BY_TRANSFER | Name in use by an ordinary transfer | A live sender on a name with a waiting or streaming ordinary sender or receiver | Use another name, or wait until ?status reads idle |
TRANSFER_ID_IN_USE | Transfer id in use | transfer is held by a waiting, running or finished transfer (any name) or by a kept receipt | Pick a new id for each transfer |
SDK usage
Section titled “SDK usage”# 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 stdinecho 'hello' | curl -T - "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath"
# Send a tar.gz of a directorytar 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 receiverscurl -T backup.tar.gz "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/backup?n=2"OPTIONS /api/v1/pipe/{path}
Section titled “OPTIONS /api/v1/pipe/{path}”Handles CORS preflight requests for cross-origin browser access. Returns permissive CORS headers reflecting the request Origin.
Headers returned:
Access-Control-Allow-Origin— reflectsOrigin(or*if none/null)Access-Control-Allow-Methods—GET, POST, PUT, OPTIONS(HEADis supported same-origin on reserved paths only)Access-Control-Allow-Headers—Content-Type, Content-Disposition, Authorization, X-Piping, X-Hoody-PipeAccess-Control-Allow-Credentials—true(whenOriginis 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.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
path | path | string | Yes | Any path — OPTIONS is handled identically for all paths. |
This endpoint accepts no request body.
Response
Section titled “Response”CORS preflight response with permissive headers.
SDK usage
Section titled “SDK usage”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"HEAD /api/v1/pipe/{path}
Section titled “HEAD /api/v1/pipe/{path}”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.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
path | path | string | Yes | Pipe path name. |
status | query | string | Yes | Must be on (?status, true, yes, 1). |
This endpoint accepts no request body.
Response
Section titled “Response”Returns the headers of the ?status snapshot (Content-Type: application/json, Cache-Control: no-store).
HEAD without ?status on a pipe path.
[ERROR] Method HEAD is not allowed. Use GET, POST, or PUT.| Error Code | Title | Description | Resolution |
|---|---|---|---|
METHOD_NOT_ALLOWED | HEAD needs ?status | HEAD is supported on pipe paths only with ?status | Add ?status, or use GET |
SDK usage
Section titled “SDK usage”curl -I "https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com/api/v1/pipe/mypath?status"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://67e89abc123def456789abcd-890abcdef12345678901cdef-pipe-1.node-example-1.containers.hoody.com', token: process.env.HOODY_TOKEN });
// headersOnly sends HEAD ?status; the SDK always adds `status` itself.await client.pipe.getStatus('mypath', { headersOnly: true });